diff --git a/electrum/electrumabc/bitcoin.py b/electrum/electrumabc/bitcoin.py index 3c8af8520..ddf0b7897 100644 --- a/electrum/electrumabc/bitcoin.py +++ b/electrum/electrumabc/bitcoin.py @@ -1,1938 +1,1938 @@ # -*- coding: utf-8 -*- # -*- mode: python3 -*- # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2011 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. import base64 import hashlib import hmac import os from enum import Enum, IntEnum from typing import TYPE_CHECKING, Optional, Tuple, Union import ecdsa import pyaes from ecdsa.curves import SECP256k1 from ecdsa.ecdsa import curve_secp256k1, generator_secp256k1 from ecdsa.ellipticcurve import Point from ecdsa.util import number_to_string, string_to_number from . import networks from .ecc_fast import do_monkey_patching_of_python_ecdsa_internals_with_libsecp256k1 from .printerror import print_error from .util import ( InvalidPassword, assert_bytes, bfh, bh2u, inv_dict, to_bytes, to_string, ) if TYPE_CHECKING: from .address import Address # Ensure Python interpreter is not running with -O, since this entire # codebase depends on "assert" not being a no-op. try: assert False except AssertionError: pass else: import sys from .constants import PROJECT_NAME sys.exit( f'{PROJECT_NAME} uses "assert" statements for its normal control' ' flow.\nPlease run this application without the python "-O" ' "(optimize) flag." ) # /End -O check do_monkey_patching_of_python_ecdsa_internals_with_libsecp256k1() # transactions FEE_STEP = 10000 COINBASE_MATURITY = 100 CASH = 100 # supported types of transction outputs TYPE_ADDRESS = 0 TYPE_PUBKEY = 1 TYPE_SCRIPT = 2 # AES encryption try: from Cryptodome.Cipher import AES except ImportError: AES = None # Derived from Bitcoin ABC src/script/script.h class OpCodes(IntEnum): # push value OP_0 = 0x00 OP_FALSE = OP_0 OP_PUSHDATA1 = 0x4C OP_PUSHDATA2 = 0x4D OP_PUSHDATA4 = 0x4E OP_1NEGATE = 0x4F OP_RESERVED = 0x50 OP_1 = 0x51 OP_TRUE = OP_1 OP_2 = 0x52 OP_3 = 0x53 OP_4 = 0x54 OP_5 = 0x55 OP_6 = 0x56 OP_7 = 0x57 OP_8 = 0x58 OP_9 = 0x59 OP_10 = 0x5A OP_11 = 0x5B OP_12 = 0x5C OP_13 = 0x5D OP_14 = 0x5E OP_15 = 0x5F OP_16 = 0x60 # control OP_NOP = 0x61 OP_VER = 0x62 OP_IF = 0x63 OP_NOTIF = 0x64 OP_VERIF = 0x65 OP_VERNOTIF = 0x66 OP_ELSE = 0x67 OP_ENDIF = 0x68 OP_VERIFY = 0x69 OP_RETURN = 0x6A # stack ops OP_TOALTSTACK = 0x6B OP_FROMALTSTACK = 0x6C OP_2DROP = 0x6D OP_2DUP = 0x6E OP_3DUP = 0x6F OP_2OVER = 0x70 OP_2ROT = 0x71 OP_2SWAP = 0x72 OP_IFDUP = 0x73 OP_DEPTH = 0x74 OP_DROP = 0x75 OP_DUP = 0x76 OP_NIP = 0x77 OP_OVER = 0x78 OP_PICK = 0x79 OP_ROLL = 0x7A OP_ROT = 0x7B OP_SWAP = 0x7C OP_TUCK = 0x7D # splice ops OP_CAT = 0x7E OP_SPLIT = 0x7F # after monolith upgrade (May 2018) OP_NUM2BIN = 0x80 # after monolith upgrade (May 2018) OP_BIN2NUM = 0x81 # after monolith upgrade (May 2018) OP_SIZE = 0x82 # bit logic OP_INVERT = 0x83 OP_AND = 0x84 OP_OR = 0x85 OP_XOR = 0x86 OP_EQUAL = 0x87 OP_EQUALVERIFY = 0x88 OP_RESERVED1 = 0x89 OP_RESERVED2 = 0x8A # numeric OP_1ADD = 0x8B OP_1SUB = 0x8C OP_2MUL = 0x8D OP_2DIV = 0x8E OP_NEGATE = 0x8F OP_ABS = 0x90 OP_NOT = 0x91 OP_0NOTEQUAL = 0x92 OP_ADD = 0x93 OP_SUB = 0x94 OP_MUL = 0x95 OP_DIV = 0x96 OP_MOD = 0x97 OP_LSHIFT = 0x98 OP_RSHIFT = 0x99 OP_BOOLAND = 0x9A OP_BOOLOR = 0x9B OP_NUMEQUAL = 0x9C OP_NUMEQUALVERIFY = 0x9D OP_NUMNOTEQUAL = 0x9E OP_LESSTHAN = 0x9F OP_GREATERTHAN = 0xA0 OP_LESSTHANOREQUAL = 0xA1 OP_GREATERTHANOREQUAL = 0xA2 OP_MIN = 0xA3 OP_MAX = 0xA4 OP_WITHIN = 0xA5 # crypto OP_RIPEMD160 = 0xA6 OP_SHA1 = 0xA7 OP_SHA256 = 0xA8 OP_HASH160 = 0xA9 OP_HASH256 = 0xAA OP_CODESEPARATOR = 0xAB OP_CHECKSIG = 0xAC OP_CHECKSIGVERIFY = 0xAD OP_CHECKMULTISIG = 0xAE OP_CHECKMULTISIGVERIFY = 0xAF # expansion OP_NOP1 = 0xB0 OP_CHECKLOCKTIMEVERIFY = 0xB1 OP_NOP2 = OP_CHECKLOCKTIMEVERIFY OP_CHECKSEQUENCEVERIFY = 0xB2 OP_NOP3 = OP_CHECKSEQUENCEVERIFY OP_NOP4 = 0xB3 OP_NOP5 = 0xB4 OP_NOP6 = 0xB5 OP_NOP7 = 0xB6 OP_NOP8 = 0xB7 OP_NOP9 = 0xB8 OP_NOP10 = 0xB9 # More crypto OP_CHECKDATASIG = 0xBA OP_CHECKDATASIGVERIFY = 0xBB # additional byte string operations OP_REVERSEBYTES = 0xBC class InvalidPadding(Exception): pass class KeyIsBip38Error(ValueError): """Raised by deserialize_privkey to signify a key is a bip38 encrypted '6P' key.""" def append_PKCS7_padding(data): assert_bytes(data) padlen = 16 - (len(data) % 16) return data + bytes([padlen]) * padlen def strip_PKCS7_padding(data): assert_bytes(data) if len(data) % 16 != 0 or len(data) == 0: raise InvalidPadding("invalid length") padlen = data[-1] if padlen > 16: raise InvalidPadding("invalid padding byte (large)") for i in data[-padlen:]: if i != padlen: raise InvalidPadding("invalid padding byte (inconsistent)") return data[0:-padlen] def aes_encrypt_with_iv(key, iv, data): assert_bytes(key, iv, data) data = append_PKCS7_padding(data) if AES: e = AES.new(key, AES.MODE_CBC, iv).encrypt(data) else: aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) aes = pyaes.Encrypter(aes_cbc, padding=pyaes.PADDING_NONE) e = aes.feed(data) + aes.feed() # empty aes.feed() flushes buffer return e def aes_decrypt_with_iv(key, iv, data): assert_bytes(key, iv, data) if AES: cipher = AES.new(key, AES.MODE_CBC, iv) data = cipher.decrypt(data) else: aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) aes = pyaes.Decrypter(aes_cbc, padding=pyaes.PADDING_NONE) data = aes.feed(data) + aes.feed() # empty aes.feed() flushes buffer try: return strip_PKCS7_padding(data) except InvalidPadding: raise InvalidPassword() def EncodeAES_bytes(secret, msg): """Params and retval are all bytes objects.""" assert_bytes(msg) iv = bytes(os.urandom(16)) ct = aes_encrypt_with_iv(secret, iv, msg) return iv + ct def EncodeAES_base64(secret, msg): """Returns base64 encoded ciphertext. Params and retval are all bytes.""" e = EncodeAES_bytes(secret, msg) return base64.b64encode(e) def DecodeAES_bytes(secret, ciphertext): assert_bytes(ciphertext) iv, e = ciphertext[:16], ciphertext[16:] s = aes_decrypt_with_iv(secret, iv, e) return s def DecodeAES_base64(secret, ciphertext_b64): ciphertext = bytes(base64.b64decode(ciphertext_b64)) return DecodeAES_bytes(secret, ciphertext) def pw_encode(s, password): if password: secret = Hash(password) return EncodeAES_base64(secret, to_bytes(s, "utf8")).decode("utf8") else: return s def pw_decode(s, password): if password is not None: secret = Hash(password) try: d = to_string(DecodeAES_base64(secret, s), "utf8") except Exception: raise InvalidPassword() return d else: return s def rev_hex(s: str) -> str: """Reverse the byte order for a string representation of a hexadecimal number. The input string must only contain hexadecimal characters, and its length must be a multiple of two. :: >>> rev_hex("a2b3") 'b3a2' """ return bytes.fromhex(s)[::-1].hex() def int_to_hex(i: int, length: int = 1) -> str: """Return a little-endian hexadecimal representation of an integer. :: >>> int_to_hex(8, 1) '08' >>> int_to_hex(8, 2) '0800' >>> int_to_hex(32001, 3) '017d00' :param i: Integer to be represented. :param length: Length in bytes of the hexadecimal number to be represented. Each byte is represented as two characters. """ s = hex(i)[2:].rstrip("L") s = "0" * (2 * length - len(s)) + s return rev_hex(s) def var_int(i: int) -> str: """ Encode an integer as a hex representation of a variable length integer. See: https://en.bitcoin.it/wiki/Protocol_specification#Variable_length_integer """ if i < 0xFD: return int_to_hex(i) elif i <= 0xFFFF: return "fd" + int_to_hex(i, 2) elif i <= 0xFFFFFFFF: return "fe" + int_to_hex(i, 4) else: return "ff" + int_to_hex(i, 8) def op_push_bytes(data_len: int) -> bytes: assert isinstance(data_len, int) and data_len >= 0 if data_len < OpCodes.OP_PUSHDATA1: return data_len.to_bytes(byteorder="little", length=1) elif data_len <= 0xFF: return bytes([OpCodes.OP_PUSHDATA1]) + data_len.to_bytes( byteorder="little", length=1 ) elif data_len <= 0xFFFF: return bytes([OpCodes.OP_PUSHDATA2]) + data_len.to_bytes( byteorder="little", length=2 ) else: return bytes([OpCodes.OP_PUSHDATA4]) + data_len.to_bytes( byteorder="little", length=4 ) def op_push(i: int) -> str: """Hex version of above""" return op_push_bytes(i).hex() def push_script_bytes(data: Union[bytearray, bytes], *, minimal=True) -> bytes: """Returns pushed data to the script, automatically respecting BIP62 "minimal encoding" rules. If `minimal` is False, will not use BIP62 and will just push using OP_PUSHDATA*, etc (this non-BIP62 way of pushing is the convention in OP_RETURN scripts such as CashAccounts usually). Input data is bytes, returns bytes.""" assert isinstance(data, (bytes, bytearray)) data_len = len(data) if minimal: # BIP62 has bizarre rules for minimal pushes of length 0 or 1 # See: https://en.bitcoin.it/wiki/BIP_0062#Push_operators if data_len == 0 or data_len == 1 and data[0] == 0: return bytes([OpCodes.OP_0]) elif data_len == 1 and 1 <= data[0] <= 16: return bytes([OpCodes.OP_1 + (data[0] - 1)]) elif data_len == 1 and data[0] == 0x81: return bytes([OpCodes.OP_1NEGATE]) return op_push_bytes(data_len) + data def push_script(data: str, *, minimal=True) -> str: """Returns pushed data to the script, automatically respecting BIP62 "minimal encoding" rules. Input data is hex, returns hex.""" return push_script_bytes(bytes.fromhex(data), minimal=minimal).hex() def sha256(x): x = to_bytes(x, "utf8") return bytes(hashlib.sha256(x).digest()) def Hash(x): x = to_bytes(x, "utf8") out = bytes(sha256(sha256(x))) return out def hmac_oneshot(key, msg, digest): """Params key, msg and return val are bytes. Digest is a hashlib algorithm, e.g. hashlib.sha512""" if hasattr(hmac, "digest"): # requires python 3.7+; faster return hmac.digest(key, msg, digest) else: return hmac.new(key, msg, digest).digest() def hash_encode(x): return bh2u(x[::-1]) def hash_decode(x): return bytes.fromhex(x)[::-1] def hmac_sha_512(x, y): return hmac_oneshot(x, y, hashlib.sha512) # pywallet openssl private key implementation def i2o_ECPublicKey(pubkey, compressed=False): # public keys are 65 bytes long (520 bits) # 0x04 + 32-byte X-coordinate + 32-byte Y-coordinate # 0x00 = point at infinity, 0x02 and 0x03 = compressed, 0x04 = uncompressed # compressed keys: where is 0x02 if y is even and 0x03 if y is odd if compressed: if pubkey.point.y() & 1: key = "03" + "%064x" % pubkey.point.x() else: key = "02" + "%064x" % pubkey.point.x() else: key = "04" + "%064x" % pubkey.point.x() + "%064x" % pubkey.point.y() return bfh(key) # end pywallet openssl private key implementation # functions from pywallet def hash_160(public_key: bytes) -> bytes: sha256_hash = sha256(public_key) try: md = hashlib.new("ripemd160") md.update(sha256_hash) return md.digest() except ValueError: from Crypto.Hash import RIPEMD160 md = RIPEMD160.new() md.update(sha256_hash) return md.digest() except ImportError: from . import ripemd md = ripemd.new(sha256_hash) return md.digest() def hash160_to_b58_address(h160, addrtype): s = bytes([addrtype]) s += h160 return base_encode(s + Hash(s)[0:4], base=58) def b58_address_to_hash160(addr): addr = to_bytes(addr, "ascii") # will raise ValueError on bad characters _bytes = base_decode(addr, 25, base=58) return _bytes[0], _bytes[1:21] def hash160_to_p2pkh(h160, *, net=None): if net is None: net = networks.net return hash160_to_b58_address(h160, net.ADDRTYPE_P2PKH) def hash160_to_p2sh(h160, *, net=None): if net is None: net = networks.net return hash160_to_b58_address(h160, net.ADDRTYPE_P2SH) def public_key_to_p2pkh(public_key, *, net=None): if net is None: net = networks.net return hash160_to_p2pkh(hash_160(public_key), net=net) def pubkey_to_address(txin_type, pubkey, *, net=None): if net is None: net = networks.net if txin_type == "p2pkh": return public_key_to_p2pkh(bfh(pubkey), net=net) else: raise NotImplementedError(txin_type) def script_to_address(script): from .transaction import get_address_from_output_script t, addr = get_address_from_output_script(bfh(script)) assert t == TYPE_ADDRESS return addr def public_key_to_p2pk_script(pubkey): script = push_script(pubkey) script += "ac" # op_checksig return script __b58chars = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" assert len(__b58chars) == 58 __b43chars = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:" assert len(__b43chars) == 43 def base_encode(v, base): """encode v, which is a string of bytes, to base58.""" assert_bytes(v) if base not in (58, 43): raise ValueError(f"not supported base: {base}") chars = __b58chars if base == 43: chars = __b43chars long_value = 0 power_of_base = 1 for c in v[::-1]: # naive but slow variant: long_value += (256**i) * c long_value += power_of_base * c power_of_base <<= 8 result = bytearray() while long_value >= base: div, mod = divmod(long_value, base) result.append(chars[mod]) long_value = div result.append(chars[long_value]) # Bitcoin does a little leading-zero-compression: # leading 0-bytes in the input become leading-1s nPad = 0 for c in v: if c == 0x00: nPad += 1 else: break result.extend([chars[0]] * nPad) result.reverse() return result.decode("ascii") def base_decode(v, length, base): """decode v into a string of len bytes. May raise ValueError on bad chars in string.""" # assert_bytes(v) v = to_bytes(v, "ascii") if base not in (58, 43): raise ValueError(f"not supported base: {base}") chars = __b58chars if base == 43: chars = __b43chars long_value = 0 power_of_base = 1 for c in v[::-1]: digit = chars.find(bytes((c,))) if digit < 0: raise ValueError( "Forbidden character '{}' for base {}".format(chr(c), base) ) # naive but slow variant: long_value += digit * (base**i) long_value += digit * power_of_base power_of_base *= base result = bytearray() while long_value >= 256: div, mod = divmod(long_value, 256) result.append(mod) long_value = div result.append(long_value) nPad = 0 for c in v: if c == chars[0]: nPad += 1 else: break result.extend(b"\x00" * nPad) if length is not None and len(result) != length: return None result.reverse() return bytes(result) def EncodeBase58Check(vchIn): - hash = Hash(vchIn) - return base_encode(vchIn + hash[0:4], base=58) + h = Hash(vchIn) + return base_encode(vchIn + h[0:4], base=58) def DecodeBase58Check(psz): """Returns None on failure""" try: vchRet = base_decode(psz, None, base=58) except ValueError: # Bad characters in string return None key = vchRet[0:-4] csum = vchRet[-4:] - hash = Hash(key) - cs32 = hash[0:4] + h = Hash(key) + cs32 = h[0:4] if cs32 != csum: return None else: return key SCRIPT_TYPES = { "p2pkh": 0, "p2sh": 5, } def serialize_privkey(secret, compressed, txin_type, *, net=None): if net is None: net = networks.net prefix = bytes([(SCRIPT_TYPES[txin_type] + net.WIF_PREFIX) & 255]) suffix = b"\01" if compressed else b"" vchIn = prefix + secret + suffix return EncodeBase58Check(vchIn) def deserialize_privkey(key, *, net=None): """Returns the deserialized key if key is a WIF key (non bip38), raises otherwise.""" # whether the pubkey is compressed should be visible from the keystore if net is None: net = networks.net vch = DecodeBase58Check(key) if is_bip38_key(key): raise KeyIsBip38Error("bip38") if is_minikey(key): return "p2pkh", minikey_to_private_key(key), False elif vch: txin_type = inv_dict(SCRIPT_TYPES)[vch[0] - net.WIF_PREFIX] # We do it this way because eg iOS runs with PYTHONOPTIMIZE=1 if len(vch) not in ( 33, 34, ): raise AssertionError("Key {} has invalid length".format(key)) compressed = len(vch) == 34 if compressed and vch[33] != 0x1: raise ValueError( "Invalid WIF key. Length suggests compressed pubkey, " "but last byte is 0x{:02x} != 0x01".format(vch[33]) ) return txin_type, vch[1:33], compressed else: raise ValueError("cannot deserialize", key) def regenerate_key(pk): assert len(pk) == 32 return ECKey(pk) def GetPubKey(pubkey, compressed=False): return i2o_ECPublicKey(pubkey, compressed) def GetSecret(pkey): return bfh("%064x" % pkey.secret) def is_compressed(sec, *, net=None): if net is None: net = networks.net return deserialize_privkey(sec, net=net)[2] def public_key_from_private_key(pk, compressed): pkey = regenerate_key(pk) public_key = GetPubKey(pkey.pubkey, compressed) return bh2u(public_key) def address_from_private_key(sec, *, net=None): if net is None: net = networks.net txin_type, privkey, compressed = deserialize_privkey(sec, net=net) public_key = public_key_from_private_key(privkey, compressed) return pubkey_to_address(txin_type, public_key, net=net) def is_private_key(key, *, net=None): """Returns True if key is a WIF key (and also non bip38)""" if net is None: net = networks.net try: k = deserialize_privkey(key, net=net) return k is not False except Exception: return False # end pywallet functions def is_minikey(text): # Minikeys are typically 22 or 30 characters, but this routine # permits any length of 20 or more provided the minikey is valid. # A valid minikey must begin with an 'S', be in base58, and when # suffixed with '?' have its SHA256 hash begin with a zero byte. # They are widely used in Casascius physical bitcoins, where the # address corresponded to an uncompressed public key. return ( len(text) >= 20 and text[0] == "S" and all(ord(c) in __b58chars for c in text) and sha256(text + "?")[0] == 0x00 ) def minikey_to_private_key(text): return sha256(text) class SignatureType(Enum): ECASH = 1 BITCOIN = 2 ECASH_MSG_MAGIC = b"eCash Signed Message:\n" BITCOIN_MSG_MAGIC = b"Bitcoin Signed Message:\n" def msg_magic(message: bytes, sigtype: SignatureType = SignatureType.ECASH) -> bytes: """Prepare the preimage of the message before signing it or verifying its signature.""" magic = ECASH_MSG_MAGIC if sigtype == SignatureType.ECASH else BITCOIN_MSG_MAGIC length = bytes.fromhex(var_int(len(message))) magic_length = bytes.fromhex(var_int(len(magic))) return magic_length + magic + length + message def verify_message( address: Union[str, "Address"], sig: bytes, message: bytes, *, net: Optional[networks.AbstractNet] = None, sigtype: SignatureType = SignatureType.ECASH, ) -> bool: if net is None: net = networks.net assert_bytes(sig, message) # Fixme: circular import address -> bitcoin -> address from .address import Address if not isinstance(address, Address): address = Address.from_string(address, net=net) h = Hash(msg_magic(message, sigtype)) public_key, compressed = pubkey_from_signature(sig, h) # check public key using the right address pubkey = point_to_ser(public_key.pubkey.point, compressed) addr = Address.from_pubkey(pubkey) if address != addr: return False # check message try: public_key.verify_digest(sig[1:], h, sigdecode=ecdsa.util.sigdecode_string) except Exception: return False return True def encrypt_message(message, pubkey, magic=b"BIE1"): return ECKey.encrypt_message(message, bfh(pubkey), magic) def ECC_YfromX(x, curved=curve_secp256k1, odd=True): _p = curved.p() _a = curved.a() _b = curved.b() for offset in range(128): Mx = x + offset My2 = pow(Mx, 3, _p) + _a * pow(Mx, 2, _p) + _b % _p My = pow(My2, (_p + 1) // 4, _p) if curved.contains_point(Mx, My): if odd == bool(My & 1): return [My, offset] return [_p - My, offset] raise Exception("ECC_YfromX: No Y found") def negative_point(P): return Point(P.curve(), P.x(), -P.y(), P.order()) def point_to_ser(P, comp=True): if comp: return bfh(("%02x" % (2 + (P.y() & 1))) + ("%064x" % P.x())) return bfh("04" + ("%064x" % P.x()) + ("%064x" % P.y())) def ser_to_point(Aser): curve = curve_secp256k1 generator = generator_secp256k1 _r = generator.order() assert Aser[0] in [0x02, 0x03, 0x04] if Aser[0] == 0x04: return Point( curve, string_to_number(Aser[1:33]), string_to_number(Aser[33:]), _r ) Mx = string_to_number(Aser[1:]) return Point(curve, Mx, ECC_YfromX(Mx, curve, Aser[0] == 0x03)[0], _r) class MyVerifyingKey(ecdsa.VerifyingKey): @classmethod def from_signature(klass, sig, recid, h, curve): """See http://www.secg.org/download/aid-780/sec1-v2.pdf, chapter 4.1.6""" from ecdsa import numbertheory, util from . import msqr curveFp = curve.curve G = curve.generator order = G.order() # extract r,s from signature r, s = util.sigdecode_string(sig, order) # 1.1 x = r + (recid // 2) * order # 1.3 alpha = (x * x * x + curveFp.a() * x + curveFp.b()) % curveFp.p() beta = msqr.modular_sqrt(alpha, curveFp.p()) y = beta if (beta - recid) % 2 == 0 else curveFp.p() - beta # 1.4 the constructor checks that nR is at infinity R = Point(curveFp, x, y, order) # 1.5 compute e from message: e = string_to_number(h) minus_e = -e % order # 1.6 compute Q = r^-1 (sR - eG) inv_r = numbertheory.inverse_mod(r, order) Q = inv_r * (s * R + minus_e * G) return klass.from_public_point(Q, curve) def pubkey_from_signature(sig, h): if len(sig) != 65: raise Exception("Wrong encoding") nV = sig[0] if nV < 27 or nV >= 35: raise Exception("Bad encoding") if nV >= 31: compressed = True nV -= 4 else: compressed = False recid = nV - 27 return MyVerifyingKey.from_signature(sig[1:], recid, h, curve=SECP256k1), compressed class MySigningKey(ecdsa.SigningKey): """Enforce low S values in signatures""" def sign_number(self, number, entropy=None, k=None): curve = SECP256k1 G = curve.generator order = G.order() r, s = ecdsa.SigningKey.sign_number(self, number, entropy, k) if s > order // 2: s = order - s return r, s class ECKey(object): def __init__(self, k): secret = string_to_number(k) self.pubkey = ecdsa.ecdsa.Public_key( generator_secp256k1, generator_secp256k1 * secret ) self.privkey = ecdsa.ecdsa.Private_key(self.pubkey, secret) self.secret = secret def GetPubKey(self, compressed): return GetPubKey(self.pubkey, compressed) def get_public_key(self, compressed=True): return bh2u(point_to_ser(self.pubkey.point, compressed)) def sign(self, msg_hash): private_key = MySigningKey.from_secret_exponent(self.secret, curve=SECP256k1) public_key = private_key.get_verifying_key() signature = private_key.sign_digest_deterministic( msg_hash, hashfunc=hashlib.sha256, sigencode=ecdsa.util.sigencode_string ) assert public_key.verify_digest( signature, msg_hash, sigdecode=ecdsa.util.sigdecode_string ) return signature def sign_message(self, message, is_compressed, sigtype=SignatureType.ECASH): message = to_bytes(message, "utf8") signature = self.sign(Hash(msg_magic(message, sigtype))) for i in range(4): sig = bytes([27 + i + (4 if is_compressed else 0)]) + signature try: self.verify_message(sig, message, sigtype) return sig except Exception: continue else: raise Exception("error: cannot sign message") def verify_message(self, sig, message, sigtype=SignatureType.ECASH): assert_bytes(message) h = Hash(msg_magic(message, sigtype)) public_key, compressed = pubkey_from_signature(sig, h) # check public key if point_to_ser(public_key.pubkey.point, compressed) != point_to_ser( self.pubkey.point, compressed ): raise Exception("Bad signature") # check message public_key.verify_digest(sig[1:], h, sigdecode=ecdsa.util.sigdecode_string) # ECIES encryption/decryption methods; AES-128-CBC with PKCS7 is used as the cipher; hmac-sha256 is used as the mac @classmethod def encrypt_message(self, message, pubkey, magic=b"BIE1"): assert_bytes(message) pk = ser_to_point(pubkey) if not ecdsa.ecdsa.point_is_valid(generator_secp256k1, pk.x(), pk.y()): raise Exception("invalid pubkey") ephemeral_exponent = number_to_string( ecdsa.util.randrange(pow(2, 256)), generator_secp256k1.order() ) ephemeral = ECKey(ephemeral_exponent) ecdh_key = point_to_ser(pk * ephemeral.privkey.secret_multiplier) key = hashlib.sha512(ecdh_key).digest() iv, key_e, key_m = key[0:16], key[16:32], key[32:] ciphertext = aes_encrypt_with_iv(key_e, iv, message) ephemeral_pubkey = bfh(ephemeral.get_public_key(compressed=True)) encrypted = magic + ephemeral_pubkey + ciphertext mac = hmac.new(key_m, encrypted, hashlib.sha256).digest() return base64.b64encode(encrypted + mac) def decrypt_message(self, encrypted, magic=b"BIE1"): encrypted = base64.b64decode(encrypted) if len(encrypted) < 85: raise Exception("invalid ciphertext: length") magic_found = encrypted[:4] ephemeral_pubkey = encrypted[4:37] ciphertext = encrypted[37:-32] mac = encrypted[-32:] if magic_found != magic: raise Exception("invalid ciphertext: invalid magic bytes") try: ephemeral_pubkey = ser_to_point(ephemeral_pubkey) except AssertionError: raise Exception("invalid ciphertext: invalid ephemeral pubkey") if not ecdsa.ecdsa.point_is_valid( generator_secp256k1, ephemeral_pubkey.x(), ephemeral_pubkey.y() ): raise Exception("invalid ciphertext: invalid ephemeral pubkey") ecdh_key = point_to_ser(ephemeral_pubkey * self.privkey.secret_multiplier) key = hashlib.sha512(ecdh_key).digest() iv, key_e, key_m = key[0:16], key[16:32], key[32:] if mac != hmac.new(key_m, encrypted[:-32], hashlib.sha256).digest(): raise InvalidPassword() return aes_decrypt_with_iv(key_e, iv, ciphertext) # BIP32 BIP32_PRIME = 0x80000000 def get_pubkeys_from_secret(secret): # public key private_key = ecdsa.SigningKey.from_string(secret, curve=SECP256k1) public_key = private_key.get_verifying_key() K = public_key.to_string() K_compressed = GetPubKey(public_key.pubkey, True) return K, K_compressed # Child private key derivation function (from master private key) # k = master private key (32 bytes) # c = master chain code (extra entropy for key derivation) (32 bytes) # n = the index of the key we want to derive. (only 32 bits will be used) # If n is negative (i.e. the 32nd bit is set), the resulting private key's # corresponding public key can NOT be determined without the master private key. # However, if n is positive, the resulting private key's corresponding # public key can be determined without the master private key. def CKD_priv(k, c, n): is_prime = n & BIP32_PRIME return _CKD_priv(k, c, bfh(rev_hex(int_to_hex(n, 4))), is_prime) def _CKD_priv(k, c, s, is_prime): order = generator_secp256k1.order() keypair = ECKey(k) cK = GetPubKey(keypair.pubkey, True) data = bytes([0]) + k + s if is_prime else cK + s I_ = hmac.new(c, data, hashlib.sha512).digest() k_n = number_to_string( (string_to_number(I_[0:32]) + string_to_number(k)) % order, order ) c_n = I_[32:] return k_n, c_n # Child public key derivation function (from public key only) # K = master public key # c = master chain code # n = index of key we want to derive # This function allows us to find the nth public key, as long as n is # non-negative. If n is negative, we need the master private key to find it. def CKD_pub(cK, c, n): if n & BIP32_PRIME: raise return _CKD_pub(cK, c, bfh(rev_hex(int_to_hex(n, 4)))) # helper function, callable with arbitrary string def _CKD_pub(cK, c, s): I_ = hmac.new(c, cK + s, hashlib.sha512).digest() curve = SECP256k1 pubkey_point = string_to_number(I_[0:32]) * curve.generator + ser_to_point(cK) public_key = ecdsa.VerifyingKey.from_public_point(pubkey_point, curve=SECP256k1) c_n = I_[32:] cK_n = GetPubKey(public_key.pubkey, True) return cK_n, c_n def xprv_header(xtype, *, net=None): if net is None: net = networks.net return bfh("%08x" % net.XPRV_HEADERS[xtype]) def xpub_header(xtype, *, net=None): if net is None: net = networks.net return bfh("%08x" % net.XPUB_HEADERS[xtype]) def serialize_xprv( xtype, c, k, depth=0, fingerprint=b"\x00" * 4, child_number=b"\x00" * 4, *, net=None ): if net is None: net = networks.net xprv = ( xprv_header(xtype, net=net) + bytes([depth]) + fingerprint + child_number + c + bytes([0]) + k ) return EncodeBase58Check(xprv) def serialize_xpub( xtype, c, cK, depth=0, fingerprint=b"\x00" * 4, child_number=b"\x00" * 4, *, net=None, ): if net is None: net = networks.net xpub = ( xpub_header(xtype, net=net) + bytes([depth]) + fingerprint + child_number + c + cK ) return EncodeBase58Check(xpub) class InvalidXKey(Exception): pass class InvalidXKeyFormat(InvalidXKey): pass class InvalidXKeyLength(InvalidXKey): pass class InvalidXKeyNotBase58(InvalidXKey): pass def deserialize_xkey(xkey, prv, *, net=None): if net is None: net = networks.net xkey = DecodeBase58Check(xkey) if xkey is None: raise InvalidXKeyNotBase58("The supplied xkey is not encoded using base58") if len(xkey) != 78: raise InvalidXKeyLength("Invalid length") depth = xkey[4] fingerprint = xkey[5:9] child_number = xkey[9:13] c = xkey[13 : 13 + 32] header = int("0x" + bh2u(xkey[0:4]), 16) headers = net.XPRV_HEADERS if prv else net.XPUB_HEADERS if header not in headers.values(): raise InvalidXKeyFormat("Invalid xpub format", hex(header)) xtype = list(headers.keys())[list(headers.values()).index(header)] n = 33 if prv else 32 K_or_k = xkey[13 + n :] try: # The below ensures we can actually derive nodes from this key, # by first deriving node 0. Fixes #1817. if prv: CKD_priv(K_or_k, c, 0) else: CKD_pub(K_or_k, c, 0) except Exception as e: raise InvalidXKey("Cannot derive from key") from e return xtype, depth, fingerprint, child_number, c, K_or_k def deserialize_xpub(xkey, *, net=None): if net is None: net = networks.net return deserialize_xkey(xkey, False, net=net) def deserialize_xprv(xkey, *, net=None): if net is None: net = networks.net return deserialize_xkey(xkey, True, net=net) def xpub_type(x, *, net=None): if net is None: net = networks.net return deserialize_xpub(x, net=net)[0] def is_xpub(text, *, net=None): if net is None: net = networks.net try: deserialize_xpub(text, net=net) return True except Exception: return False def is_xprv(text, *, net=None): if net is None: net = networks.net try: deserialize_xprv(text, net=net) return True except Exception: return False def xpub_from_xprv(xprv, *, net=None): if net is None: net = networks.net xtype, depth, fingerprint, child_number, c, k = deserialize_xprv(xprv, net=net) K, cK = get_pubkeys_from_secret(k) return serialize_xpub(xtype, c, cK, depth, fingerprint, child_number, net=net) def bip32_root(seed, xtype, *, net=None): if net is None: net = networks.net I_ = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest() master_k = I_[0:32] master_c = I_[32:] K, cK = get_pubkeys_from_secret(master_k) xprv = serialize_xprv(xtype, master_c, master_k, net=net) xpub = serialize_xpub(xtype, master_c, cK, net=net) return xprv, xpub def xpub_from_pubkey(xtype, cK, *, net=None): if net is None: net = networks.net assert cK[0] in [0x02, 0x03] return serialize_xpub(xtype, b"\x00" * 32, cK, net=net) def bip32_derivation(s): if not s.startswith("m/"): raise ValueError("invalid bip32 derivation path: {}".format(s)) s = s[2:] for n in s.split("/"): if n == "": continue i = int(n[:-1]) + BIP32_PRIME if n[-1] == "'" else int(n) yield i def is_bip32_derivation(x): try: [i for i in bip32_derivation(x)] return True except Exception: return False def bip32_private_derivation(xprv, branch, sequence, *, net=None): if net is None: net = networks.net if not sequence.startswith(branch): raise ValueError( "incompatible branch ({}) and sequence ({})".format(branch, sequence) ) if branch == sequence: return xprv, xpub_from_xprv(xprv, net=net) xtype, depth, fingerprint, child_number, c, k = deserialize_xprv(xprv, net=net) sequence = sequence[len(branch) :] for n in sequence.split("/"): if n == "": continue i = int(n[:-1]) + BIP32_PRIME if n[-1] == "'" else int(n) parent_k = k k, c = CKD_priv(k, c, i) depth += 1 _, parent_cK = get_pubkeys_from_secret(parent_k) fingerprint = hash_160(parent_cK)[0:4] child_number = bfh("%08X" % i) K, cK = get_pubkeys_from_secret(k) xpub = serialize_xpub(xtype, c, cK, depth, fingerprint, child_number, net=net) xprv = serialize_xprv(xtype, c, k, depth, fingerprint, child_number, net=net) return xprv, xpub def bip32_public_derivation(xpub, branch, sequence, *, net=None): if net is None: net = networks.net xtype, depth, fingerprint, child_number, c, cK = deserialize_xpub(xpub, net=net) assert sequence.startswith(branch) sequence = sequence[len(branch) :] for n in sequence.split("/"): if n == "": continue i = int(n) parent_cK = cK cK, c = CKD_pub(cK, c, i) depth += 1 fingerprint = hash_160(parent_cK)[0:4] child_number = bfh("%08X" % i) return serialize_xpub(xtype, c, cK, depth, fingerprint, child_number, net=net) def bip32_private_key(sequence, k, chain): for i in sequence: k, chain = CKD_priv(k, chain, i) return k def is_bip38_available(require_fast=True): """Returns True iff we have the underlying libs to decode Bip38 (scrypt libs). Use require_fast=True if we require native code. Note that the non-native code libs are incredibly slow and not suitable for production use.""" if not Bip38Key.canDecrypt(): return False if require_fast and not Bip38Key.isFast(): return False return True def is_bip38_key(bip38str, *, net=None): """Returns True iff the '6P...' passed-in string is a valid Bip38 encrypted key. False otherwise. Does not require is_bip38_available to return a valid result.""" return Bip38Key.isBip38(bip38str, net=net) def bip38_decrypt(enc_key, password, *, require_fast=True, net=None): """Pass a bip38 key eg '6PnQ46rtBGW4XuiudqinAZYobT4Aa8GdtYkjG1LvXK3RBq6ARJA3txjj21' and a password. Both should be str's. Returns a tuple of: (decrypted_WIF_key_str, Address_object) if decoding succeeds, or an empty tuple on bad password. Returns 'None' if failed due to missing libs or because of malformed key. Use is_bip38_available() to determine if we actually can decode bip38 keys (we have the libs).""" if not is_bip38_available(require_fast): return None try: return Bip38Key(enc_key, net=net).decrypt(password) except Bip38Key.PasswordError: return tuple() # Bad password result is an empty tuple except Bip38Key.Error as e: print_error("[bip38_decrypt] Error with key", enc_key, "error was:", repr(e)) return None class Bip38Key: """ Implements Bip38 _encrypt_ and _decrypt_ functionality. Supports both ECMult and NonECMult key types, so it should work with all BIP38 keys. This code was translated from Calin's Go implementation of brute38: https://www.github.com/cculianu/brute38 Note that to actually encrypt or decrypt keys you need either: - hashlib.scrypt (python 3.6 + openssl 1.1) which is very fast. - Cryptodome.Protocol.KDF.scrypt (also fast as it's native) - Or, the slow python-only lib 'pyscrypt' which is INCREDIBLY slow. Use Bip38Key.canDecrypt() to test if the decrypt() functionality is actually available (that is, if we found a scrypt implementation). Similarly, use Bip38Key.canEncrypt() to test whether encryption works. Use Bip38Key.isFast() to determine if decrypt() will be fast or painfully slow: It can take several minutes to decode a single key if Bip38Key.isFast() is False. Example psueodo-UI code to use this class in a manner than won't drive users crazy: if Bip38Key.isBip38(userKey): # test that user input is a bip38 key if not Bip38Key.canDecrypt(): # show some GUI error that scrypt is missing here... gui.warning("You supplied a bip38 key but no scrypt lib is found!") return if not Bip38Key.isFast(): # warn user here that the operation will take MINUTES! if not gui.question("The operation will be slow.. continue?"): return # user opted out. gui.pop_up_waiting_dialog() # show user a spining waiting thing... try: pass = gui.get_password("Please enter the password for this bip38 key.") wif, addr = Bip38Key(userKey).decrypt(pass) # may be fast or slow depending on underlying lib... except Bip38Key.PasswordError: # user supplied a bad password ... gui.show_error("Invalid password!") return finally: if not Bip38Key.isFast(): gui.hide_waiting_dialog() # hide waiting dialog if shown... gui.show(wif, addr) # show WIF key and address in GUI here """ class Type: NonECMult = 0x42 ECMult = 0x43 Unknown = 0x0 enc = ( # string // bip38 base58 encoded key (as the user would see it in a paper wallet) "" ) dec = b"" # []byte // key decoded to bytes (still in encrypted form) flag = 0x0 # byte // the flag byte compressed = False # bool // boolean flag determining if compressed typ = Type.Unknown # KeyType // one of NonECMultKey or ECMultKey above salt = b"" # [] byte // the slice salt -- a slice of .dec slice entropy = b"" # [] byte // only non-nil for typ==ECMultKey -- a slice into .dec hasLotSequence = False # bool // usually false, may be true only for typ==ECMultKey # // coin / network specific info affecting key decription and address decoding: # this gets populated by current value of NetworkConstants.net.WIF_PREFIX, etc networkVersion = 0x00 # byte // usually 0x0 for BTC/BCH privateKeyPrefix = 0x80 # byte // usually 0x80 for BTC/BCH # Internal class-level vars _scrypt_1 = None _scrypt_2 = None class Error(Exception): """Decoding a BIP38 key will raise a subclass of this""" pass class DecodeError(Error): pass class PasswordError(Error, InvalidPassword): pass def __init__(self, enc, *, net=None): if isinstance(enc, (bytearray, bytes)): enc = enc.decode("ascii") assert isinstance( enc, str ), "Bip38Key must be instantiated with an encrypted bip38 key string!" if not enc.startswith("6P"): raise Bip38Key.DecodeError( "Provided bip38 key string appears to not be valid. Expected a '6P'" " prefix!" ) self.net = networks.net if net is None else net self.enc = enc self.dec = DecodeBase58Check(self.enc) if not self.dec: raise Bip38Key.DecodeError( "Cannot decode bip38 key: Failed Base58 Decode Check" ) if len(self.dec) != 39: raise Bip38Key.DecodeError( "Cannot decode bip38 key: Resulting decoded bytes are of the wrong" " length (should be 39, is {})".format(len(self.dec)) ) if self.dec[0] == 0x01 and self.dec[1] == 0x42: self.typ = Bip38Key.Type.NonECMult elif self.dec[0] == 0x01 and self.dec[1] == 0x43: self.typ = Bip38Key.Type.ECMult else: raise Bip38Key.DecodeError( "Malformed byte slice -- the specified key appears to be invalid" ) self.flag = self.dec[2] self.compressed = False if self.typ == Bip38Key.Type.NonECMult: self.compressed = self.flag == 0xE0 self.salt = self.dec[3:7] if not self.compressed and self.flag != 0xC0: raise Bip38Key.DecodeError("Invalid BIP38 compression flag") elif self.typ == Bip38Key.Type.ECMult: self.compressed = (self.flag & 0x20) != 0 self.hasLotSequence = (self.flag & 0x04) != 0 if (self.flag & 0x24) != self.flag: raise Bip38Key.DecodeError("Invalid BIP38 ECMultKey flag") if self.hasLotSequence: self.salt = self.dec[7:11] self.entropy = self.dec[7:15] else: self.salt = self.dec[7:15] self.entropy = self.salt self.networkVersion, self.privateKeyPrefix = ( self.net.ADDRTYPE_P2PKH, self.net.WIF_PREFIX, ) @property def lot(self) -> Optional[int]: """Returns the 'lot' number if 'hasLotSequence' or None otherwise.""" if self.dec and self.hasLotSequence: return self.entropy[4] * 4096 + self.entropy[5] * 16 + self.entropy[6] // 16 @property def sequence(self) -> Optional[int]: """Returns the 'sequence' number if 'hasLotSequence' or None otherwise.""" if self.dec and self.hasLotSequence: return (self.entropy[6] & 0x0F) * 256 + self.entropy[7] def typeString(self): if self.typ == Bip38Key.Type.NonECMult: return "NonECMultKey" if self.typ == Bip38Key.Type.ECMult: return "ECMultKey" return "UnknownKey" @classmethod def isBip38(cls, bip38_enc_key, *, net=None): """Returns true if the encryped key string is a valid bip38 key.""" try: cls(bip38_enc_key, net=net) return True # if we get to this point the key was successfully decoded. except cls.Error: # print_error("[Bip38Key.isBip38] {}:".format(bip38_enc_key), e) return False @staticmethod def isFast(): """Returns True if the fast hashlib.scrypt implementation is found.""" cls = __class__ if cls._scrypt_1 or cls._scrypt_2: return True if hasattr(hashlib, "scrypt"): cls._scrypt_1 = hashlib.scrypt return True else: try: from Cryptodome.Protocol.KDF import scrypt cls._scrypt_2 = scrypt return True except (ImportError, NameError): pass return False @staticmethod def canDecrypt(): """Tests if this class can decrypt. If this returns False then we are missing the scrypt module: either hashlib.scrypt or pyscrypt""" if Bip38Key.isFast(): return True try: import pyscrypt # noqa: F401 return True except ImportError: pass return False @staticmethod def canEncrypt(): return Bip38Key.canDecrypt() @staticmethod def _scrypt(password, salt, N, r, p, dkLen): password = to_bytes(password) salt = to_bytes(salt) if Bip38Key.isFast(): if __class__._scrypt_1: return __class__._scrypt_1( password=password, salt=salt, n=N, r=r, p=p, dklen=dkLen ) elif __class__._scrypt_2: return __class__._scrypt_2( password=password, salt=salt, N=N, r=r, p=p, key_len=dkLen ) raise RuntimeError( "INTERNAL ERROR -- neither _scrypt_1 or _scrypt_2 are defined, but" " isFast()==True... FIXME!" ) try: import pyscrypt except ImportError: raise Bip38Key.Error( "We lack a module to decrypt BIP38 Keys. Install either: Cryptodome" " (fast), Python + OpenSSL 1.1 (fast), or pyscrypt (slow)" ) print_error("[{}] using slow pyscrypt.hash... :(".format(__class__.__name__)) return pyscrypt.hash(password=password, salt=salt, N=N, r=r, p=p, dkLen=dkLen) def _decryptNoEC( self, passphrase: str ) -> ( tuple ): # returns the (WIF private key, Address) on success, raises Error on failure. scryptBuf = Bip38Key._scrypt( password=passphrase, salt=self.salt, N=16384, r=8, p=8, dkLen=64 ) derivedHalf1 = scryptBuf[0:32] derivedHalf2 = scryptBuf[32:64] encryptedHalf1 = self.dec[7:23] encryptedHalf2 = self.dec[23:39] h = pyaes.AESModeOfOperationECB(derivedHalf2) k1 = h.decrypt(encryptedHalf1) k2 = h.decrypt(encryptedHalf2) keyBytes = bytearray(32) for i in range(16): keyBytes[i] = k1[i] ^ derivedHalf1[i] keyBytes[i + 16] = k2[i] ^ derivedHalf1[i + 16] keyBytes = bytes(keyBytes) eckey = regenerate_key(keyBytes) pubKey = eckey.GetPubKey(self.compressed) from .address import Address # fixme addr = Address.from_pubkey(pubKey) addrHashed = Hash(addr.to_storage_string(net=self.net))[0:4] assert len(addrHashed) == len(self.salt) for i in range(len(addrHashed)): if addrHashed[i] != self.salt[i]: raise Bip38Key.PasswordError( "Supplied password failed to decrypt bip38 key." ) return serialize_privkey(keyBytes, self.compressed, "p2pkh", net=self.net), addr @staticmethod def _normalizeNFC(s: str) -> str: """Ensures unicode string is normalized to NFC standard as specified by bip38""" import unicodedata return unicodedata.normalize("NFC", s) def decrypt( self, passphrase: str ) -> Tuple[str, object]: # returns the (wifkey string, Address object) assert isinstance(passphrase, str), "Passphrase must be a string!" # ensure unicode bytes are normalized to NFC standard as specified by bip38 passphrase = self._normalizeNFC(passphrase) if self.typ == Bip38Key.Type.NonECMult: return self._decryptNoEC(passphrase) elif self.typ != Bip38Key.Type.ECMult: raise Bip38Key.Error("INTERNAL ERROR: Unknown key type") prefactorA = Bip38Key._scrypt( password=passphrase, salt=self.salt, N=16384, r=8, p=8, dkLen=32 ) if self.hasLotSequence: prefactorB = prefactorA + self.entropy passFactor = Hash(prefactorB) del prefactorB else: passFactor = prefactorA ignored, passpoint = get_pubkeys_from_secret(passFactor) encryptedpart1 = self.dec[15:23] encryptedpart2 = self.dec[23:39] derived = Bip38Key._scrypt( password=passpoint, salt=self.dec[3:7] + self.entropy, N=1024, r=1, p=1, dkLen=64, ) h = pyaes.AESModeOfOperationECB(derived[32:]) unencryptedpart2 = bytearray(h.decrypt(encryptedpart2)) for i in range(len(unencryptedpart2)): unencryptedpart2[i] ^= derived[i + 16] encryptedpart1 += bytes(unencryptedpart2[:8]) unencryptedpart1 = bytearray(h.decrypt(encryptedpart1)) for i in range(len(unencryptedpart1)): unencryptedpart1[i] ^= derived[i] seeddb = bytes(unencryptedpart1[:16]) + bytes(unencryptedpart2[8:]) factorb = Hash(seeddb) bytes_to_int = Bip38Key._bytes_to_int passFactorI = bytes_to_int(passFactor) factorbI = bytes_to_int(factorb) privKey = passFactorI * factorbI privKey = ( privKey % 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 ) int_to_bytes = Bip38Key._int_to_bytes privKey = int_to_bytes(privKey, 32) eckey = regenerate_key(privKey) pubKey = eckey.GetPubKey(self.compressed) from .address import Address # fixme addr = Address.from_pubkey(pubKey) addrHashed = Hash(addr.to_storage_string(net=self.net))[0:4] for i in range(len(addrHashed)): if addrHashed[i] != self.dec[3 + i]: raise Bip38Key.PasswordError( "Supplied password failed to decrypt bip38 key." ) return serialize_privkey(privKey, self.compressed, "p2pkh", net=self.net), addr @classmethod def encrypt(cls, wif: str, passphrase: str, *, net=None) -> object: """Returns a Bip38Key instance encapsulating the supplied WIF key encrypted with passphrase. May raise on bad/garbage WIF or other bad arguments.""" assert cls.canEncrypt(), "scrypt function missing. Cannot encrypt." assert isinstance(passphrase, str), "Passphrase must be a string!" if net is None: net = networks.net _type, key_bytes, compressed = deserialize_privkey(wif, net=net) # may raise if _type != "p2pkh": raise ValueError( "Only p2pkh WIF keys may be encrypted using BIP38 at this time." ) public_key = public_key_from_private_key(key_bytes, compressed) addr_str = pubkey_to_address(_type, public_key, net=net) addr_hash = Hash(addr_str)[0:4] # ensure unicode bytes are normalized to NFC standard as specified by bip38 passphrase = cls._normalizeNFC(passphrase) derived_key = cls._scrypt(passphrase, addr_hash, N=16384, r=8, p=8, dkLen=64) derivedHalf1 = derived_key[:32] derivedHalf2 = derived_key[32:] h = pyaes.AESModeOfOperationECB(derivedHalf2) # Encrypt bitcoinprivkey[0...15] xor derivedhalf1[0...15] encryptedHalf1 = h.encrypt( bytes((x[0] ^ x[1]) for x in zip(key_bytes[:16], derivedHalf1[:16])) ) encryptedHalf2 = h.encrypt( bytes((x[0] ^ x[1]) for x in zip(key_bytes[16:], derivedHalf1[16:])) ) flag = 0xE0 if compressed else 0xC0 b38 = ( bytes((0x01, cls.Type.NonECMult)) + bytes((flag,)) + to_bytes(addr_hash) + encryptedHalf1 + encryptedHalf2 ) return cls(EncodeBase58Check(b38)) _ec_mult_magic_prefix = bytes.fromhex("2CE9B3E1FF39E2") @classmethod def createECMult( cls, passphrase: str, lot_sequence: Optional[Tuple[int, int]] = None, compressed=True, *, net=None, ) -> object: """Creates a new, randomly generated and encrypted "EC Mult" Bip38 key as per the Bip38 spec. The new key may be decrypted later with the supplied passphrase to yield a 'p2pkh' WIF private key. May raise if the scrypt function is missing. Optional arguments: `lot_sequence`, a tuple of (lot, sequence), both ints, with lot being an int in the range [0,1048575], and sequence being an int in the range [0, 4095]. This tuple, if specified, will be encoded in the generated Bip38 key as the .lot and .sequence property. `compressed` specifies whether to encode a compressed or uncompressed bitcoin pub/priv key pair. Older wallets do not support compressed keys but all new wallets do.""" assert cls.canEncrypt(), "scrypt function missing. Cannot encrypt." assert isinstance(passphrase, str), "Passphrase must be a string!" if net is None: net = networks.net passphrase = cls._normalizeNFC(passphrase) has_lot_seq = lot_sequence is not None if not has_lot_seq: # No lot_sequence ownersalt = ownerentropy = to_bytes(os.urandom(8)) magic = cls._ec_mult_magic_prefix + bytes((0x53,)) else: lot, seq = lot_sequence assert 0 <= lot <= 1048575, "Lot number out of range" assert 0 <= seq <= 4095, "Sequence number out of range" ownersalt = to_bytes(os.urandom(4)) lotseq = int(lot * 4096 + seq).to_bytes(4, byteorder="big") ownerentropy = ownersalt + lotseq magic = cls._ec_mult_magic_prefix + bytes((0x51,)) prefactor = cls._scrypt(passphrase, salt=ownersalt, N=16384, r=8, p=8, dkLen=32) if has_lot_seq: passfactor = Hash(prefactor + ownerentropy) else: passfactor = prefactor ignored, passpoint = get_pubkeys_from_secret(passfactor) # 49 bytes (not a str, despite name. We use the name from bip38 spec here) intermediate_passphrase_string = magic + ownerentropy + passpoint enc = EncodeBase58Check(intermediate_passphrase_string) return cls.ec_mult_from_intermediate_passphrase_string(enc, compressed) @classmethod def ec_mult_from_intermediate_passphrase_string( cls, enc_ips: bytes, compressed=True ) -> object: """Takes a Bip38 intermediate passphrase string as specified in the bip38 spec and generates a random and encrypted key, returning a newly constructed Bip38Key instance.""" ips = DecodeBase58Check(enc_ips) assert ips.startswith(cls._ec_mult_magic_prefix), "Bad intermediate string" hls_byte = ips[7] assert hls_byte in (0x51, 0x53), "Bad has_lot_seq byte" has_lot_seq = hls_byte == 0x51 ownerentropy = ips[8:16] # 8 bytes passpoint = ips[16:] # 33 bytes assert len(passpoint) == 33, "Bad passpoint length" # set up flag byte flag = 0x20 if compressed else 0x0 if has_lot_seq: flag |= 0x04 seedb = os.urandom(24) factorb = Hash(seedb) point = ser_to_point(passpoint) * cls._bytes_to_int(factorb) pubkey = point_to_ser(point, compressed) generatedaddress = pubkey_to_address("p2pkh", pubkey.hex()) addresshash = Hash(generatedaddress)[:4] salt = addresshash + ownerentropy derived = cls._scrypt(passpoint, salt=salt, N=1024, r=1, p=1, dkLen=64) derivedhalf1 = derived[:32] derivedhalf2 = derived[32:] h = pyaes.AESModeOfOperationECB(derivedhalf2) encryptedpart1 = h.encrypt( bytes((x[0] ^ x[1]) for x in zip(seedb[:16], derivedhalf1[:16])) ) encryptedpart2 = h.encrypt( bytes( (x[0] ^ x[1]) for x in zip(encryptedpart1[8:] + seedb[16:24], derivedhalf1[16:]) ) ) return cls( EncodeBase58Check( bytes((0x01, cls.Type.ECMult, flag)) + addresshash + ownerentropy + encryptedpart1[:8] + encryptedpart2 ) ) @staticmethod def _int_to_bytes(value, length): result = [] for i in range(0, length): result.append(value >> (i * 8) & 0xFF) result.reverse() return bytes(result) @staticmethod def _bytes_to_int(by): result = 0 for b in by: result = result * 256 + int(b) return result def __repr__(self): ret = "<{}:".format(self.__class__.__name__) d = dir(self) for x in d: a = getattr(self, x) if not x.startswith("_") and isinstance(a, (int, bytes, bool, str)): if x == "typ": a = self.typeString() elif isinstance(a, int) and not isinstance(a, bool): a = "0x" + bh2u(self._int_to_bytes(a, 1)) elif isinstance(a, bytes): a = "0x" + bh2u(a) if a else a ret += " {}={}".format(x, a) ret += ">" return ret def __str__(self): return self.enc diff --git a/electrum/electrumabc/blockchain.py b/electrum/electrumabc/blockchain.py index bf4a678bd..9806e3a58 100644 --- a/electrum/electrumabc/blockchain.py +++ b/electrum/electrumabc/blockchain.py @@ -1,772 +1,772 @@ # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2012 thomasv@ecdsa.org # # 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 os import threading from typing import Dict, Optional from . import asert_daa, bitcoin, networks, util from .printerror import PrintError class VerifyError(Exception): """Exception used for blockchain verification errors.""" CHUNK_FORKS = -3 CHUNK_BAD = -2 CHUNK_LACKED_PROOF = -1 CHUNK_ACCEPTED = 0 HEADER_SIZE = 80 # bytes MAX_BITS = 0x1D00FFFF # https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/chainparams.cpp#L95 # compact: 0x1d00ffff MAX_TARGET = 0x00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF # FIXME: if it is always constant, move to network constants MAX_BITS_REGTEST = 0x207FFFFF # indicates no header in data file NULL_HEADER = bytes([0]) * HEADER_SIZE NULL_HASH_BYTES = bytes([0]) * 32 NULL_HASH_HEX = NULL_HASH_BYTES.hex() def bits_to_work(bits): target = bits_to_target(bits) if not (0 < target < (1 << 256)): return 0 return (1 << 256) // (target + 1) def _get_little_endian_num_bits(b: bytes) -> int: """Returns 1 + the position of the highest bit that is set in bytes b or 0 if the bytes object is all 0's. Like Bitcoin ABC's arith_uint256::bits() """ width = len(b) for pos in range(width - 1, -1, -1): if b[pos]: for nbits in range(7, 0, -1): if b[pos] & (1 << nbits): return 8 * pos + nbits + 1 return 8 * pos + 1 return 0 def _get_little_endian_low64(b: bytes) -> int: """Like Bitcoin ABC's arith_uint256::GetLow64()""" assert len(b) >= 8 return ( int.from_bytes(b[:8], byteorder="little", signed=False) & 0xFF_FF_FF_FF_FF_FF_FF_FF ) def target_to_bits(target: int) -> int: # arith_uint256::GetCompact in Bitcoin ABC if not (0 <= target < (1 << 256)): raise Exception(f"target should be uint256. got {target!r}") b = target.to_bytes(length=32, byteorder="little", signed=False) nsize = (_get_little_endian_num_bits(b) + 7) // 8 if nsize <= 3: ncompact = (_get_little_endian_low64(b) << (8 * (3 - nsize))) & 0xFFFFFFFF else: bn = (target >> (8 * (nsize - 3))).to_bytes( length=32, byteorder="little", signed=False ) ncompact = _get_little_endian_low64(bn) & 0xFFFFFFFF # The 0x00800000 bit denotes the sign. # Thus, if it is already set, divide the mantissa by 256 and increase the # exponent. if ncompact & 0x00800000: ncompact >>= 8 nsize += 1 assert (ncompact & ~0x007FFFFF) == 0 assert nsize < 256 ncompact |= nsize << 24 return ncompact def bits_to_target(ncompact: int) -> int: # arith_uint256::SetCompact in Bitcoin ABC if not (0 <= ncompact < (1 << 32)): raise Exception(f"ncompact should be uint32. got {ncompact!r}") nsize = ncompact >> 24 nword = ncompact & 0x007FFFFF if nsize <= 3: nword >>= 8 * (3 - nsize) ret = nword else: ret = nword ret <<= 8 * (nsize - 3) # Check for negative, bit 24 represents sign of N if nword != 0 and (ncompact & 0x00800000) != 0: raise Exception("target cannot be negative") if nword != 0 and ( (nsize > 34) or (nword > 0xFF and nsize > 33) or (nword > 0xFFFF and nsize > 32) ): raise Exception("target has overflown") return ret def serialize_header(res): s = ( bitcoin.int_to_hex(res.get("version"), 4) + bitcoin.rev_hex(res.get("prev_block_hash")) + bitcoin.rev_hex(res.get("merkle_root")) + bitcoin.int_to_hex(int(res.get("timestamp")), 4) + bitcoin.int_to_hex(int(res.get("bits")), 4) + bitcoin.int_to_hex(int(res.get("nonce")), 4) ) return s def deserialize_header(s, height): h = {} h["version"] = int.from_bytes(s[0:4], "little") h["prev_block_hash"] = bitcoin.hash_encode(s[4:36]) h["merkle_root"] = bitcoin.hash_encode(s[36:68]) h["timestamp"] = int.from_bytes(s[68:72], "little") h["bits"] = int.from_bytes(s[72:76], "little") h["nonce"] = int.from_bytes(s[76:80], "little") h["block_height"] = height return h def hash_header_hex(header_hex): return bitcoin.hash_encode(bitcoin.Hash(bytes.fromhex(header_hex))) def hash_header(header): if header is None: return NULL_HASH_HEX if header.get("prev_block_hash") is None: header["prev_block_hash"] = "00" * 32 return hash_header_hex(serialize_header(header)) blockchains = {} def read_blockchains(config) -> Dict[int, Blockchain]: blockchains[0] = Blockchain(config, 0, None) fdir = os.path.join(util.get_headers_dir(config), "forks") if not os.path.exists(fdir): os.mkdir(fdir) fork_filenames = filter(lambda x: x.startswith("fork_"), os.listdir(fdir)) fork_filenames = sorted(fork_filenames, key=lambda x: int(x.split("_")[1])) for filename in fork_filenames: parent_base_height = int(filename.split("_")[1]) base_height = int(filename.split("_")[2]) b = Blockchain(config, base_height, parent_base_height) blockchains[b.base_height] = b return blockchains def check_header(header): if type(header) is not dict: return False for b in blockchains.values(): if b.check_header(header): return b return False def can_connect(header): for b in blockchains.values(): if b.can_connect(header): return b return False def verify_proven_chunk(chunk_base_height, chunk_data): chunk = HeaderChunk(chunk_base_height, chunk_data) header_count = len(chunk_data) // HEADER_SIZE prev_header_hash = None for i in range(header_count): header = chunk.get_header_at_index(i) # Check the chain of hashes for all headers preceding the proven one. this_header_hash = hash_header(header) if i > 0: if prev_header_hash != header.get("prev_block_hash"): raise VerifyError( "prev hash mismatch: %s vs %s" % (prev_header_hash, header.get("prev_block_hash")) ) prev_header_hash = this_header_hash # Copied from electrumx -def root_from_proof(hash, branch, index): +def root_from_proof(hash_, branch, index): hash_func = bitcoin.Hash for elt in branch: if index & 1: - hash = hash_func(elt + hash) + hash_ = hash_func(elt + hash_) else: - hash = hash_func(hash + elt) + hash_ = hash_func(hash_ + elt) index >>= 1 if index: raise ValueError("index out of range for branch") - return hash + return hash_ class HeaderChunk: def __init__(self, base_height, data): self.base_height = base_height self.header_count = len(data) // HEADER_SIZE self.headers = [ deserialize_header( data[i * HEADER_SIZE : (i + 1) * HEADER_SIZE], base_height + i ) for i in range(self.header_count) ] def __repr__(self): return "HeaderChunk(base_height={}, header_count={})".format( self.base_height, self.header_count ) def get_count(self): return self.header_count def contains_height(self, height): return self.base_height <= height < self.base_height + self.header_count def get_header_at_height(self, height): assert self.contains_height(height) return self.get_header_at_index(height - self.base_height) def get_header_at_index(self, index): return self.headers[index] class Blockchain(PrintError): """ Manages blockchain headers and their verification """ def __init__(self, config, base_height, parent_base_height): self.config = config self.catch_up = None # interface catching up self.base_height = base_height self.parent_base_height = parent_base_height self.lock = threading.Lock() with self.lock: self.update_size() def __repr__(self): return "<{}.{} {}>".format(__name__, type(self).__name__, self.format_base()) def format_base(self): return "{}@{}".format(self.get_name(), self.get_base_height()) def parent(self): return blockchains[self.parent_base_height] def get_max_child(self): children = list( filter( lambda y: y.parent_base_height == self.base_height, blockchains.values() ) ) return max([x.base_height for x in children]) if children else None def get_base_height(self): mc = self.get_max_child() return mc if mc is not None else self.base_height def get_branch_size(self): return self.height() - self.get_base_height() + 1 def get_name(self): return self.get_hash(self.get_base_height()).lstrip("00")[0:10] def check_header(self, header): header_hash = hash_header(header) height = header.get("block_height") return header_hash == self.get_hash(height) def fork(parent, header): base_height = header.get("block_height") self = Blockchain(parent.config, base_height, parent.base_height) open(self.path(), "w+").close() self.save_header(header) return self def height(self): return self.base_height + self.size() - 1 def size(self): with self.lock: return self._size def update_size(self): p = self.path() self._size = os.path.getsize(p) // HEADER_SIZE if os.path.exists(p) else 0 def verify_header(self, header, prev_header, bits=None): prev_header_hash = hash_header(prev_header) this_header_hash = hash_header(header) if prev_header_hash != header.get("prev_block_hash"): raise VerifyError( "prev hash mismatch: %s vs %s" % (prev_header_hash, header.get("prev_block_hash")) ) # We do not need to check the block difficulty if the chain of linked header # hashes was proven correct against our checkpoint. if bits is not None: # checkpoint BitcoinCash fork block if ( header.get("block_height") == networks.net.BITCOIN_CASH_FORK_BLOCK_HEIGHT and hash_header(header) != networks.net.BITCOIN_CASH_FORK_BLOCK_HASH ): err_str = "block at height %i is not cash chain fork block. hash %s" % ( header.get("block_height"), hash_header(header), ) raise VerifyError(err_str) if bits != header.get("bits"): raise VerifyError( "bits mismatch: %s vs %s" % (bits, header.get("bits")) ) target = bits_to_target(bits) if int("0x" + this_header_hash, 16) > target: raise VerifyError( "insufficient proof of work: %s vs target %s" % (int("0x" + this_header_hash, 16), target) ) def verify_chunk(self, chunk_base_height, chunk_data): chunk = HeaderChunk(chunk_base_height, chunk_data) prev_header = None if chunk_base_height != 0: prev_header = self.read_header(chunk_base_height - 1) header_count = len(chunk_data) // HEADER_SIZE for i in range(header_count): header = chunk.get_header_at_index(i) # Check the chain of hashes and the difficulty. bits = self.get_bits(header, chunk) self.verify_header(header, prev_header, bits) prev_header = header def path(self): d = util.get_headers_dir(self.config) filename = ( "blockchain_headers" if self.parent_base_height is None else os.path.join( "forks", "fork_%d_%d" % (self.parent_base_height, self.base_height) ) ) return os.path.join(d, filename) def save_chunk(self, base_height, chunk_data): chunk_offset = (base_height - self.base_height) * HEADER_SIZE if chunk_offset < 0: chunk_data = chunk_data[-chunk_offset:] chunk_offset = 0 # Headers at and before the verification checkpoint are sparsely filled. # Those should be overwritten and should not truncate the chain. top_height = base_height + (len(chunk_data) // HEADER_SIZE) - 1 truncate = top_height > networks.net.VERIFICATION_BLOCK_HEIGHT self.write(chunk_data, chunk_offset, truncate) self.swap_with_parent() def swap_with_parent(self): if self.parent_base_height is None: return parent_branch_size = self.parent().height() - self.base_height + 1 if parent_branch_size >= self.size(): return self.print_error("swap", self.base_height, self.parent_base_height) parent_base_height = self.parent_base_height base_height = self.base_height parent = self.parent() with open(self.path(), "rb") as f: my_data = f.read() with open(parent.path(), "rb") as f: f.seek((base_height - parent.base_height) * HEADER_SIZE) parent_data = f.read(parent_branch_size * HEADER_SIZE) self.write(parent_data, 0) parent.write(my_data, (base_height - parent.base_height) * HEADER_SIZE) # store file path for b in blockchains.values(): b.old_path = b.path() # swap parameters self.parent_base_height = parent.parent_base_height parent.parent_base_height = parent_base_height self.base_height = parent.base_height parent.base_height = base_height self._size = parent._size parent._size = parent_branch_size # move files for b in blockchains.values(): if b in [self, parent]: continue if b.old_path != b.path(): self.print_error("renaming", b.old_path, b.path()) os.rename(b.old_path, b.path()) # update pointers blockchains[self.base_height] = self blockchains[parent.base_height] = parent def write(self, data, offset, truncate=True): filename = self.path() with self.lock: with open(filename, "rb+") as f: if truncate and offset != self._size * HEADER_SIZE: f.seek(offset) f.truncate() f.seek(offset) f.write(data) f.flush() os.fsync(f.fileno()) self.update_size() def save_header(self, header): delta = header.get("block_height") - self.base_height data = bytes.fromhex(serialize_header(header)) assert delta == self.size() assert len(data) == HEADER_SIZE self.write(data, delta * HEADER_SIZE) self.swap_with_parent() def read_header(self, height, chunk=None): # If the read is done within an outer call with local unstored header data, we # first look in the chunk data currently being processed. if chunk is not None and chunk.contains_height(height): return chunk.get_header_at_height(height) assert self.parent_base_height != self.base_height if height < 0: return if height < self.base_height: return self.parent().read_header(height) if height > self.height(): return delta = height - self.base_height name = self.path() if os.path.exists(name): with open(name, "rb") as f: f.seek(delta * HEADER_SIZE) h = f.read(HEADER_SIZE) # Is it a pre-checkpoint header that has never been requested? if h == NULL_HEADER: return None return deserialize_header(h, height) def get_hash(self, height): if height == -1: return NULL_HASH_HEX elif height == 0: return networks.net.GENESIS return hash_header(self.read_header(height)) def get_median_time_past(self, height, chunk=None): if height < 0: return 0 times = [ self.read_header(h, chunk)["timestamp"] for h in range(max(0, height - 10), height + 1) ] return sorted(times)[len(times) // 2] def get_suitable_block_height(self, suitableheight, chunk=None): # In order to avoid a block in a very skewed timestamp to have too much # influence, we select the median of the 3 top most block as a start point # Reference: github.com/Bitcoin-ABC/bitcoin-abc/master/src/pow.cpp#L201 assert suitableheight >= 3 blocks2 = self.read_header(suitableheight, chunk) blocks1 = self.read_header(suitableheight - 1, chunk) blocks = self.read_header(suitableheight - 2, chunk) if blocks["timestamp"] > blocks2["timestamp"]: blocks, blocks2 = blocks2, blocks if blocks["timestamp"] > blocks1["timestamp"]: blocks, blocks1 = blocks1, blocks if blocks1["timestamp"] > blocks2["timestamp"]: blocks1, blocks2 = blocks2, blocks1 return blocks1["block_height"] # cached Anchor, per-Blockchain instance, only used if the checkpoint for # this network is *behind* the anchor block _cached_asert_anchor: Optional[asert_daa.Anchor] = None def get_asert_anchor(self, prevheader, mtp, chunk=None): """Returns the asert_anchor either from Networks.net if hardcoded or calculated in realtime if not.""" if networks.net.asert_daa.anchor is not None: # Checkpointed (hard-coded) value exists, just use that return networks.net.asert_daa.anchor # Bug note: The below does not work if we don't have all the intervening # headers -- therefore this execution path should only be taken for networks # where the checkpoint block is before the anchor block. This means that # adding a checkpoint after the anchor block without setting the anchor # block in networks.net.asert_daa.anchor will result in bugs. if ( self._cached_asert_anchor is not None and self._cached_asert_anchor.height <= prevheader["block_height"] ): return self._cached_asert_anchor anchor = prevheader activation_mtp = networks.net.asert_daa.MTP_ACTIVATION_TIME while mtp >= activation_mtp: ht = anchor["block_height"] prev = self.read_header(ht - 1, chunk) if prev is None: self.print_error("get_asert_anchor missing header {}".format(ht - 1)) return None prev_mtp = self.get_median_time_past(ht - 1, chunk) if prev_mtp < activation_mtp: # Ok, use this as anchor -- since it is the first in the chain # after activation. bits = anchor["bits"] self._cached_asert_anchor = asert_daa.Anchor( ht, bits, prev["timestamp"] ) return self._cached_asert_anchor mtp = prev_mtp anchor = prev def get_bits(self, header, chunk=None): """Return bits for the given height.""" # Difficulty adjustment interval? height = header["block_height"] # Genesis if height == 0: return MAX_BITS prior = self.read_header(height - 1, chunk) if prior is None: raise Exception( "get_bits missing header {} with chunk {!r}".format(height - 1, chunk) ) bits = prior["bits"] # NOV 13 HF DAA and/or ASERT DAA prevheight = height - 1 daa_mtp = self.get_median_time_past(prevheight, chunk) # ASERTi3-2d DAA activated on Nov. 15th 2020 HF # on regtest it is disabled if ( daa_mtp >= networks.net.asert_daa.MTP_ACTIVATION_TIME and not networks.net.REGTEST ): header_ts = header["timestamp"] prev_ts = prior["timestamp"] if networks.net.TESTNET: # testnet 20 minute rule if header_ts - prev_ts > 20 * 60: return MAX_BITS anchor = self.get_asert_anchor(prior, daa_mtp, chunk) assert ( anchor is not None ), "Failed to find ASERT anchor block for chain {!r}".format(self) return networks.net.asert_daa.next_bits_aserti3_2d( anchor.bits, prev_ts - anchor.prev_time, prevheight - anchor.height ) # Mon Nov 13 19:06:40 2017 DAA HF if prevheight >= networks.net.CW144_HEIGHT: if networks.net.TESTNET: # testnet 20 minute rule if header["timestamp"] - prior["timestamp"] > 20 * 60: return MAX_BITS # determine block range daa_starting_height = self.get_suitable_block_height( prevheight - 144, chunk ) daa_ending_height = self.get_suitable_block_height(prevheight, chunk) # calculate cumulative work (EXcluding work from block daa_starting_height, # INcluding work from block daa_ending_height) daa_cumulative_work = 0 for daa_i in range(daa_starting_height + 1, daa_ending_height + 1): daa_prior = self.read_header(daa_i, chunk) daa_bits_for_a_block = daa_prior["bits"] daa_work_for_a_block = bits_to_work(daa_bits_for_a_block) daa_cumulative_work += daa_work_for_a_block # calculate and sanitize elapsed time daa_starting_timestamp = self.read_header(daa_starting_height, chunk)[ "timestamp" ] daa_ending_timestamp = self.read_header(daa_ending_height, chunk)[ "timestamp" ] daa_elapsed_time = daa_ending_timestamp - daa_starting_timestamp # NOTE: the below assume 600 second block times if daa_elapsed_time > 172800: daa_elapsed_time = 172800 if daa_elapsed_time < 43200: daa_elapsed_time = 43200 # calculate and return new target daa_Wn = (daa_cumulative_work * 600) // daa_elapsed_time daa_target = ((1 << 256) // daa_Wn) - 1 if daa_target > MAX_TARGET: return MAX_BITS return target_to_bits(daa_target) # END OF NOV-2017 DAA N_BLOCKS = networks.net.LEGACY_POW_RETARGET_BLOCKS # Normally 2016 if height % N_BLOCKS == 0: return self.get_new_bits(height, chunk) if networks.net.TESTNET: # testnet 20 minute rule if header["timestamp"] - prior["timestamp"] > 20 * 60: return MAX_BITS # special case for a newly started testnet (such as testnet4) if height < N_BLOCKS: if networks.net.REGTEST: return MAX_BITS_REGTEST return MAX_BITS return self.read_header(height // N_BLOCKS * N_BLOCKS, chunk)["bits"] # bitcoin cash EDA # Can't go below minimum, so early bail if bits == MAX_BITS: return bits mtp_6blocks = self.get_median_time_past( height - 1, chunk ) - self.get_median_time_past(height - 7, chunk) if mtp_6blocks < 12 * 3600: return bits # If it took over 12hrs to produce the last 6 blocks, increase the # target by 25% (reducing difficulty by 20%). target = bits_to_target(bits) target += target >> 2 if target > MAX_TARGET: return MAX_BITS return target_to_bits(target) def get_new_bits(self, height, chunk=None): N_BLOCKS = networks.net.LEGACY_POW_RETARGET_BLOCKS assert height % N_BLOCKS == 0 # Genesis if height == 0: return MAX_BITS first = self.read_header(height - N_BLOCKS, chunk) prior = self.read_header(height - 1, chunk) prior_target = bits_to_target(prior["bits"]) target_span = ( networks.net.LEGACY_POW_TARGET_TIMESPAN ) # usually: 14 * 24 * 60 * 60 = 2 weeks span = prior["timestamp"] - first["timestamp"] span = min(max(span, target_span // 4), target_span * 4) new_target = (prior_target * span) // target_span if new_target > MAX_TARGET: return MAX_BITS return target_to_bits(new_target) def can_connect(self, header, check_height=True): height = header["block_height"] if check_height and self.height() != height - 1: return False if height == 0: return hash_header(header) == networks.net.GENESIS previous_header = self.read_header(height - 1) if not previous_header: return False prev_hash = hash_header(previous_header) if prev_hash != header.get("prev_block_hash"): return False bits = self.get_bits(header) try: self.verify_header(header, previous_header, bits) except VerifyError as e: self.print_error( "verify header {} failed at height {:d}: {}".format( hash_header(header), height, e ) ) return False return True def connect_chunk(self, base_height, hexdata, proof_was_provided=False): chunk = HeaderChunk(base_height, hexdata) header_count = len(hexdata) // HEADER_SIZE top_height = base_height + header_count - 1 # We know that chunks before the checkpoint height, end at the checkpoint # height, and will be guaranteed to be covered by the checkpointing. If no # proof is provided then this is wrong. if top_height <= networks.net.VERIFICATION_BLOCK_HEIGHT: if not proof_was_provided: return CHUNK_LACKED_PROOF # We do not truncate when writing chunks before the checkpoint, and there's # no way at this time to know if we have this chunk, or even a consecutive # subset. So just overwrite it. elif ( base_height < networks.net.VERIFICATION_BLOCK_HEIGHT and proof_was_provided ): # This was the initial verification request which gets us enough leading # headers that we can calculate difficulty and verify the headers that we # add to this chain above the verification block height. if top_height <= self.height(): return CHUNK_ACCEPTED elif base_height != self.height() + 1: # This chunk covers a segment of this blockchain which we already have # headers for. We need to verify that there isn't a split within the chunk, # and if there is, indicate the need for the server to fork. intersection_height = min(top_height, self.height()) chunk_header = chunk.get_header_at_height(intersection_height) our_header = self.read_header(intersection_height) if hash_header(chunk_header) != hash_header(our_header): return CHUNK_FORKS if intersection_height <= self.height(): return CHUNK_ACCEPTED else: # This base of this chunk joins to the top of the blockchain in theory. # We need to rule out the case where the chunk is actually a fork at the # connecting height. our_header = self.read_header(self.height()) chunk_header = chunk.get_header_at_height(base_height) if hash_header(our_header) != chunk_header["prev_block_hash"]: return CHUNK_FORKS try: if not proof_was_provided: self.verify_chunk(base_height, hexdata) self.save_chunk(base_height, hexdata) return CHUNK_ACCEPTED except VerifyError as e: self.print_error("verify_chunk failed: {}".format(e)) return CHUNK_BAD diff --git a/electrum/electrumabc/commands.py b/electrum/electrumabc/commands.py index c2221e141..20df8657a 100644 --- a/electrum/electrumabc/commands.py +++ b/electrum/electrumabc/commands.py @@ -1,1519 +1,1519 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2011 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. import argparse import ast import base64 import datetime import json import os import queue import sys import time from decimal import Decimal as PyDecimal # Qt 5.12 also exports Decimal from functools import wraps from . import bitcoin, util from .address import Address, AddressError from .bitcoin import CASH, TYPE_ADDRESS, hash_160 from .constants import PROJECT_NAME, SCRIPT_NAME, XEC from .mnemo import MnemonicElectrum, make_bip39_words from .paymentrequest import PR_EXPIRED, PR_PAID, PR_UNCONFIRMED, PR_UNKNOWN, PR_UNPAID from .plugins import run_hook from .printerror import print_error from .simple_config import SimpleConfig from .transaction import OPReturn, Transaction, TxOutput, multisig_script, tx_from_str from .util import format_satoshis, json_decode, to_bytes from .version import PACKAGE_VERSION from .wallet import create_new_wallet, restore_wallet_from_text known_commands = {} def satoshis(amount): # satoshi conversion must not be performed by the parser return int(CASH * PyDecimal(amount)) if amount not in ["!", None] else amount def assertOutpoint(out: str): """Perform some basic sanity checks on a string that represents a transaction outpoint. Namely, 64 characters and a non-negative integer separated by a colon.""" prevoutParts = out.split(":") assert len(prevoutParts) == 2, "invalid outpoint" prevout_hash, prevout_n = prevoutParts prevout_hash = bytes.fromhex(prevout_hash) assert len(prevout_hash) == 32, f"{prevout_hash.hex()} should be a 32-byte hash" assert int(prevout_n) >= 0, f"invalid output index {prevout_n}" class Command: def __init__(self, func, s): self.name = func.__name__ self.requires_network = "n" in s self.requires_wallet = "w" in s self.requires_password = "p" in s self.description = func.__doc__ self.help = self.description.split(".")[0] if self.description else None varnames = func.__code__.co_varnames[1 : func.__code__.co_argcount] self.defaults = func.__defaults__ if self.defaults: n = len(self.defaults) self.params = list(varnames[:-n]) self.options = list(varnames[-n:]) else: self.params = list(varnames) self.options = [] self.defaults = [] def __repr__(self): return "".format(self) def __str__(self): return "{}({})".format( self.name, ", ".join( self.params + [ "{}={!r}".format(name, self.defaults[i]) for i, name in enumerate(self.options) ] ), ) def command(s): def decorator(func): global known_commands name = func.__name__ known_commands[name] = Command(func, s) @wraps(func) def func_wrapper(*args, **kwargs): c = known_commands[func.__name__] wallet = args[0].wallet network = args[0].network password = kwargs.get("password") if c.requires_network and network is None: raise RuntimeError("Daemon offline") # Same wording as in daemon.py. if c.requires_wallet and wallet is None: raise RuntimeError( f"Wallet not loaded. Use '{SCRIPT_NAME} daemon load_wallet'" ) if ( c.requires_password and password is None and wallet.has_password() and not kwargs.get("unsigned") ): return {"error": "Password required"} return func(*args, **kwargs) return func_wrapper return decorator class Commands: def __init__(self, config, wallet, network, daemon=None, callback=None): self.config = config self.wallet = wallet self.daemon = daemon self.network = network self._callback = callback def _run(self, method, *args, password_getter=None, **kwargs): # this wrapper is called from the python console cmd = known_commands[method] if cmd.requires_password and self.wallet.has_password(): password = password_getter() if password is None: return else: password = None f = getattr(self, method) if cmd.requires_password: kwargs.update(password=password) result = f(*args, **kwargs) if self._callback: self._callback() return result @staticmethod def _EnsureDictNamedTuplesAreJSONSafe(d): """Address, ScriptOutput and other objects contain bytes. They cannot be serialized using JSON. This makes sure they get serialized properly by calling .to_ui_string() on them. See issue #638""" def DoChk(v): def ChkList(l_): for i in range(0, len(l_)): l_[i] = DoChk(l_[i]) # recurse return l_ def EncodeNamedTupleObject(nt): if hasattr(nt, "to_ui_string"): return nt.to_ui_string() return nt if isinstance(v, tuple): v = EncodeNamedTupleObject(v) elif isinstance(v, list): v = ChkList(v) # may recurse elif isinstance(v, dict): v = Commands._EnsureDictNamedTuplesAreJSONSafe(v) # recurse return v for k in d.keys(): d[k] = DoChk(d[k]) return d @command("") def addressconvert(self, address): """Convert to/from Legacy <-> Cash Address. Address can be either a legacy or a Cash Address and both forms will be returned as a JSON dict.""" try: addr = Address.from_string(address) except Exception as e: raise AddressError(f"Invalid address: {address}") from e return { "cashaddr": addr.to_full_string(Address.FMT_CASHADDR), "bitcoincashaddr": addr.to_full_string(Address.FMT_CASHADDR_BCH), "legacy": addr.to_full_string(Address.FMT_LEGACY), } @command("") def commands(self): """List of commands""" return " ".join(sorted(known_commands.keys())) @command("n") def getinfo(self): """network info""" net_params = self.network.get_parameters() response = { "path": self.network.config.path, "server": net_params[0], "blockchain_height": self.network.get_local_height(), "server_height": self.network.get_server_height(), "spv_nodes": len(self.network.get_interfaces()), "connected": self.network.is_connected(), "auto_connect": net_params[4], "version": PACKAGE_VERSION, "default_wallet": self.config.get_wallet_path(), "wallets": {k: w.is_up_to_date() for k, w in self.daemon.wallets.items()}, "fee_per_kb": self.config.fee_per_kb(), } return response @command("n") def stop(self): """Stop daemon""" self.daemon.stop() return "Daemon stopped" @command("n") def list_wallets(self): """List wallets open in daemon""" return [ {"path": k, "synchronized": w.is_up_to_date()} for k, w in self.daemon.wallets.items() ] @command("n") def load_wallet(self): """Open wallet in daemon""" path = self.config.get_wallet_path() wallet = self.daemon.load_wallet(path, self.config.get("password")) if wallet is not None: self.wallet = wallet response = wallet is not None return response @command("n") def close_wallet(self): """Close wallet""" path = self.config.get_wallet_path() if path in self.daemon.wallets: self.daemon.stop_wallet(path) response = True else: response = False return response @command("") def create( self, passphrase=None, password=None, encrypt_file=True, seed_type=None, wallet_path=None, ): """Create a new wallet. If you want to be prompted for an argument, type '?' or ':' (concealed) """ d = create_new_wallet( path=wallet_path, passphrase=passphrase, password=password, encrypt_file=encrypt_file, seed_type=seed_type, ) return { "seed": d["seed"], "path": d["wallet"].storage.path, "msg": d["msg"], } @command("") def restore( self, text, passphrase=None, password=None, encrypt_file=True, wallet_path=None ): """Restore a wallet from text. Text can be a seed phrase, a master public key, a master private key, a list of eCash addresses or private keys. If you want to be prompted for an argument, type '?' or ':' (concealed) """ d = restore_wallet_from_text( text, path=wallet_path, passphrase=passphrase, password=password, encrypt_file=encrypt_file, config=self.config, ) return { "path": d["wallet"].storage.path, "msg": d["msg"], } @command("wp") def password(self, password=None, new_password=None): """Change wallet password.""" b = self.wallet.storage.is_encrypted() self.wallet.update_password(password, new_password, b) self.wallet.storage.write() return {"password": self.wallet.has_password()} @command("w") def get(self, key): """Return item from wallet storage""" return self.wallet.storage.get(key) @command("") def getconfig(self, key): """Return a configuration variable.""" return self.config.get(key) @classmethod def _setconfig_normalize_value(cls, key, value): if key not in ("rpcuser", "rpcpassword"): value = json_decode(value) try: value = ast.literal_eval(value) except Exception: pass return value @command("") def setconfig(self, key, value): """Set a configuration variable. 'value' may be a string or a Python expression.""" value = self._setconfig_normalize_value(key, value) self.config.set_key(key, value) return True @command("") def make_electrum_seed(self, nbits=132, entropy=1, language=None): """Create an Electrum seed""" t = "electrum" s = MnemonicElectrum(language).make_seed(t, nbits, custom_entropy=entropy) return s @command("") def make_seed(self, nbits=128, language=None): """Create a BIP39 seed""" s = make_bip39_words("english") return s @command("") def check_electrum_seed(self, seed, entropy=1, language=None): """Check that an Electrum seed was generated with given entropy""" return MnemonicElectrum(language).check_seed(seed, entropy) @command("") def check_seed(self, seed, entropy=1, language=None): """This command is deprecated and will fail, use check_electrum_seed instead.""" raise NotImplementedError( "check_seed has been removed. Use check_electrum_seed instead." ) @command("n") def getaddresshistory(self, address): """Return the transaction history of any address. Note: This is a walletless server query, results are not checked by SPV. """ sh = Address.from_string(address).to_scripthash_hex() return self.network.synchronous_get(("blockchain.scripthash.get_history", [sh])) @command("w") def listunspent(self): """List unspent outputs. Returns the list of unspent transaction outputs in your wallet.""" coins = self.wallet.get_utxos(exclude_frozen=False) for coin in coins: if coin["value"] is not None: coin["value"] = str(PyDecimal(coin["value"]) / CASH) coin["address"] = coin["address"].to_ui_string() return coins @command("n") def getaddressunspent(self, address): """Returns the UTXO list of any address. Note: This is a walletless server query, results are not checked by SPV. """ sh = Address.from_string(address).to_scripthash_hex() return self.network.synchronous_get(("blockchain.scripthash.listunspent", [sh])) @command("") def serialize(self, jsontx): """Create a transaction from json inputs. Inputs must have a redeemPubkey. Outputs must be a list of {'address':address, 'value':satoshi_amount}. """ keypairs = {} inputs = jsontx.get("inputs") outputs = jsontx.get("outputs") locktime = jsontx.get("locktime", 0) for txin in inputs: if txin.get("output"): prevout_hash, prevout_n = txin["output"].split(":") txin["prevout_n"] = int(prevout_n) txin["prevout_hash"] = prevout_hash sec = txin.get("privkey") if sec: txin_type, privkey, compressed = bitcoin.deserialize_privkey(sec) pubkey = bitcoin.public_key_from_private_key(privkey, compressed) keypairs[pubkey] = privkey, compressed txin["type"] = txin_type txin["x_pubkeys"] = [pubkey] txin["signatures"] = [None] txin["num_sig"] = 1 outputs = [ TxOutput(TYPE_ADDRESS, Address.from_string(x["address"]), int(x["value"])) for x in outputs ] tx = Transaction.from_io( inputs, outputs, locktime=locktime, sign_schnorr=self.wallet and self.wallet.is_schnorr_enabled(), ) tx.sign(keypairs) return tx.as_dict() @command("wp") def signtransaction(self, tx, privkey=None, password=None): """Sign a transaction. The wallet keys will be used unless a private key is provided.""" tx = Transaction( tx, sign_schnorr=self.wallet and self.wallet.is_schnorr_enabled() ) if privkey: txin_type, privkey2, compressed = bitcoin.deserialize_privkey(privkey) pubkey = bitcoin.public_key_from_private_key(privkey2, compressed) tx.sign({pubkey: (privkey2, compressed)}) else: self.wallet.sign_transaction(tx, password) return tx.as_dict() @command("") def deserialize(self, tx): """Deserialize a serialized transaction""" tx = Transaction(tx) return self._EnsureDictNamedTuplesAreJSONSafe(tx.deserialize().copy()) @command("n") def broadcast(self, tx): """Broadcast a transaction to the network.""" tx = Transaction(tx) return self.network.broadcast_transaction(tx) @command("") def createmultisig(self, num, pubkeys): """Create multisig address""" assert isinstance(pubkeys, list), (type(num), type(pubkeys)) redeem_script = multisig_script(pubkeys, num) address = bitcoin.hash160_to_p2sh(hash_160(bytes.fromhex(redeem_script))) return {"address": address, "redeemScript": redeem_script} @command("w") def freeze(self, address: str): """Freeze address. Freeze the funds at one of your wallet\'s addresses""" address = Address.from_string(address) return self.wallet.set_frozen_state([address], True) @command("w") def unfreeze(self, address: str): """Unfreeze address. Unfreeze the funds at one of your wallet\'s address""" address = Address.from_string(address) return self.wallet.set_frozen_state([address], False) @command("w") def freeze_utxo(self, coin: str): """Freeze a UTXO so that the wallet will not spend it.""" assertOutpoint(coin) self.wallet.set_frozen_coin_state([coin], True) return True @command("w") def unfreeze_utxo(self, coin: str): """Unfreeze a UTXO so that the wallet might spend it.""" assertOutpoint(coin) self.wallet.set_frozen_coin_state([coin], False) return True @command("wp") def getprivatekeys(self, address, password=None): """Get private keys of addresses. You may pass a single wallet address, or a list of wallet addresses.""" def get_pk(address): address = Address.from_string(address) return self.wallet.export_private_key(address, password) if isinstance(address, str): return get_pk(address) else: return [get_pk(addr) for addr in address] @command("w") def ismine(self, address): """Check if address is in wallet. Return true if and only address is in wallet""" address = Address.from_string(address) return self.wallet.is_mine(address) @command("") def dumpprivkeys(self): """Deprecated.""" return ( "This command is deprecated. Use a pipe instead: " f"'{SCRIPT_NAME} listaddresses | {SCRIPT_NAME} " "getprivatekeys - '" ) @command("") def validateaddress(self, address): """Check that an address is valid.""" return Address.is_valid(address) @command("w") def getpubkeys(self, address): """Return the public keys for a wallet address.""" address = Address.from_string(address) return self.wallet.get_public_keys(address) @command("w") def getbalance(self): """Return the balance of your wallet.""" c, u, x = self.wallet.get_balance() out = {"confirmed": str(PyDecimal(c) / CASH)} if u: out["unconfirmed"] = str(PyDecimal(u) / CASH) if x: out["unmatured"] = str(PyDecimal(x) / CASH) return out @command("n") def getaddressbalance(self, address): """Return the balance of any address. Note: This is a walletless server query, results are not checked by SPV. """ sh = Address.from_string(address).to_scripthash_hex() out = self.network.synchronous_get(("blockchain.scripthash.get_balance", [sh])) out["confirmed"] = str(PyDecimal(out["confirmed"]) / CASH) out["unconfirmed"] = str(PyDecimal(out["unconfirmed"]) / CASH) return out @command("n") def getmerkle(self, txid, height): """Get Merkle branch of a transaction included in a block. Electron Cash uses this to verify transactions (Simple Payment Verification).""" return self.network.synchronous_get( ("blockchain.transaction.get_merkle", [txid, int(height)]) ) @command("n") def getservers(self): """Return the list of available servers""" return self.network.get_servers() @command("") def version(self): """Return the version of Electron Cash.""" from .version import PACKAGE_VERSION return PACKAGE_VERSION @command("w") def getmpk(self): """Get master public key. Return your wallet\'s master public key""" return self.wallet.get_master_public_key() @command("wp") def getmasterprivate(self, password=None): """Get master private key. Return your wallet\'s master private key""" return str(self.wallet.keystore.get_master_private_key(password)) @command("wp") def getseed(self, password=None): """Get seed phrase. Print the generation seed of your wallet.""" s = self.wallet.get_seed(password) return s @command("wp") def importprivkey(self, privkey, password=None): """Import a private key.""" if not self.wallet.can_import_privkey(): return ( "Error: This type of wallet cannot import private keys. Try to create a" " new wallet with that key." ) try: addr = self.wallet.import_private_key(privkey, password) out = "Keypair imported: " + addr except Exception as e: out = "Error: " + str(e) return out def _resolver(self, x): if x is None: return None out = self.wallet.contacts.resolve(x) if ( out.get("type") == "openalias" and self.nocheck is False and out.get("validated") is False ): raise RuntimeError("cannot verify alias", x) return out["address"] @command("n") def sweep(self, privkey, destination, fee=None, nocheck=False, imax=100): """Sweep private keys. Returns a transaction that spends UTXOs from privkey to a destination address. The transaction is not broadcasted.""" from .wallet import sweep tx_fee = satoshis(fee) privkeys = privkey.split() self.nocheck = nocheck addr = Address.from_string(destination) tx = sweep(privkeys, self.network, self.config, addr, tx_fee, imax) return tx.as_dict() if tx else None @command("wp") def signmessage(self, address, message, password=None): """Sign a message with a key. Use quotes if your message contains whitespaces""" address = Address.from_string(address) sig = self.wallet.sign_message(address, message, password) return base64.b64encode(sig).decode("ascii") @command("") def verifymessage(self, address, signature, message): """Verify a signature.""" address = Address.from_string(address) sig = base64.b64decode(signature) message = util.to_bytes(message) return bitcoin.verify_message(address, sig, message) def _mktx( self, outputs, fee=None, feerate=None, change_addr=None, domain=None, nocheck=False, unsigned=False, password=None, locktime=None, op_return=None, op_return_raw=None, addtransaction=False, ): if fee is not None and feerate is not None: raise ValueError( "Cannot specify both 'fee' and 'feerate' at the same time!" ) if op_return and op_return_raw: raise ValueError( "Both op_return and op_return_raw cannot be specified together!" ) self.nocheck = nocheck change_addr = self._resolver(change_addr) domain = None if domain is None else map(self._resolver, domain) final_outputs = [] if op_return: final_outputs.append(OPReturn.output_for_stringdata(op_return)) elif op_return_raw: try: op_return_raw = op_return_raw.strip() tmp = bytes.fromhex(op_return_raw).hex() assert tmp == op_return_raw.lower() op_return_raw = tmp except Exception as e: raise ValueError( "op_return_raw must be an even number of hex digits" ) from e final_outputs.append(OPReturn.output_for_rawhex(op_return_raw)) for address, amount in outputs: address = self._resolver(address) amount = satoshis(amount) final_outputs.append(TxOutput(TYPE_ADDRESS, address, amount)) coins = self.wallet.get_spendable_coins(domain, self.config) if feerate is not None: fee_per_kb = 1000 * PyDecimal(feerate) def fee_estimator(size): return SimpleConfig.estimate_fee_for_feerate(fee_per_kb, size) else: fee_estimator = fee tx = self.wallet.make_unsigned_transaction( coins, final_outputs, self.config, fee_estimator, change_addr ) if locktime is not None: tx.locktime = locktime if not unsigned: run_hook("sign_tx", self.wallet, tx) self.wallet.sign_transaction(tx, password) if addtransaction: self.wallet.add_transaction(tx.txid(), tx) self.wallet.add_tx_to_history(tx.txid()) self.wallet.save_transactions() return tx @command("wp") def payto( self, destination, amount, fee=None, feerate=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, password=None, locktime=None, op_return=None, op_return_raw=None, addtransaction=False, ): """Create a transaction.""" tx_fee = satoshis(fee) domain = from_addr.split(",") if from_addr else None tx = self._mktx( [(destination, amount)], tx_fee, feerate, change_addr, domain, nocheck, unsigned, password, locktime, op_return, op_return_raw, addtransaction=addtransaction, ) return tx.as_dict() @command("wp") def paytomany( self, outputs, fee=None, feerate=None, from_addr=None, change_addr=None, nocheck=False, unsigned=False, password=None, locktime=None, addtransaction=False, ): """Create a multi-output transaction.""" tx_fee = satoshis(fee) domain = from_addr.split(",") if from_addr else None tx = self._mktx( outputs, tx_fee, feerate, change_addr, domain, nocheck, unsigned, password, locktime, addtransaction=addtransaction, ) return tx.as_dict() @command("w") def history( self, year=0, show_addresses=False, show_fiat=False, use_net=False, timeout=30.0 ): """Wallet history. Returns the transaction history of your wallet.""" t0 = time.time() year, show_addresses, show_fiat, use_net, timeout = ( int(year), bool(show_addresses), bool(show_fiat), bool(use_net), float(timeout), ) def time_remaining(): return max(timeout - (time.time() - t0), 0) kwargs = { "show_addresses": show_addresses, "fee_calc_timeout": timeout, "download_inputs": use_net, } if year: start_date = datetime.datetime(year, 1, 1) end_date = datetime.datetime(year + 1, 1, 1) kwargs["from_timestamp"] = time.mktime(start_date.timetuple()) kwargs["to_timestamp"] = time.mktime(end_date.timetuple()) if show_fiat: from .exchange_rate import FxThread fakenet, q = None, None if use_net and time_remaining(): class FakeNetwork: """This simply exists to implement trigger_callback which is the only thing the FX thread calls if you pass it a 'network' object. We use it to get notified of when FX history has been downloaded.""" def __init__(self, q): self.q = q def trigger_callback(self, *args, **kwargs): self.q.put(True) q = queue.Queue() fakenet = FakeNetwork(q) fx = FxThread(self.config, fakenet) kwargs["fx"] = fx # invoke the fx to grab history rates at least once, otherwise results will # always contain "No data" (see #1671) fx.run() if fakenet and q and fx.is_enabled() and fx.get_history_config(): # queue.get docs aren't clean on whether 0 means block or don't # block, so we ensure at least 1ms timeout. # we also limit waiting for fx to 10 seconds in case it had # errors. try: q.get(timeout=min(max(time_remaining() / 2.0, 0.001), 10.0)) except queue.Empty: pass kwargs["fee_calc_timeout"] = ( time_remaining() ) # since we blocked above, recompute time_remaining for kwargs return self.wallet.export_history(**kwargs) @command("w") def setlabel(self, key, label): """Assign a label to an item. Item may be a bitcoin address address or a transaction ID""" self.wallet.set_label(key, label) @command("w") def listcontacts(self): """Show your list of contacts""" return self.wallet.contacts.get_all() @command("w") def getalias(self, key): """Retrieve alias. Lookup in your list of contacts, and for an OpenAlias DNS record.""" return self.wallet.contacts.resolve(key) @command("w") def searchcontacts(self, query): """Search through contacts, return matching entries.""" results = [] for contact in self.wallet.contacts.get_all(): lquery = query.lower() if ( lquery in contact.name.lower() or lquery.lower() in contact.address.lower() ): results.append(contact) return results @command("w") def listaddresses( self, receiving=False, change=False, labels=False, frozen=False, unused=False, funded=False, balance=False, ): """List wallet addresses. Returns the list of all addresses in your wallet. Use optional arguments to filter the results.""" out = [] for addr in self.wallet.get_addresses(): if frozen and not self.wallet.is_frozen(addr): continue if receiving and self.wallet.is_change(addr): continue if change and not self.wallet.is_change(addr): continue if unused and self.wallet.is_used(addr): continue if funded and self.wallet.is_empty(addr): continue item = addr.to_ui_string() if labels or balance: item = (item,) if balance: item += ( format_satoshis( sum(self.wallet.get_addr_balance(addr)), decimal_point=2 ), ) if labels: item += (repr(self.wallet.labels.get(addr.to_storage_string(), "")),) out.append(item) return out @command("n") def gettransaction(self, txid): """Retrieve a transaction.""" if self.wallet and txid in self.wallet.transactions: tx = self.wallet.transactions[txid] else: raw = self.network.synchronous_get(("blockchain.transaction.get", [txid])) if raw: tx = Transaction(raw) else: raise RuntimeError("Unknown transaction") return tx.as_dict() @command("") def encrypt(self, pubkey, message): """Encrypt a message with a public key. Use quotes if the message contains whitespaces.""" if not isinstance(pubkey, (str, bytes, bytearray)) or not isinstance( message, (str, bytes, bytearray) ): raise ValueError("pubkey and message text must both be strings") message = to_bytes(message) res = bitcoin.encrypt_message(message, pubkey) if isinstance(res, (bytes, bytearray)): # prevent "JSON serializable" errors in case this came from # cmdline. See #1270 res = res.decode("utf-8") return res @command("wp") def decrypt(self, pubkey, encrypted, password=None): """Decrypt a message encrypted with a public key.""" if not isinstance(pubkey, str) or not isinstance(encrypted, str): raise ValueError("pubkey and encrypted text must both be strings") res = self.wallet.decrypt_message(pubkey, encrypted, password) if isinstance(res, (bytes, bytearray)): # prevent "JSON serializable" errors in case this came from # cmdline. See #1270 res = res.decode("utf-8") return res def _format_request(self, out): pr_str = { PR_UNKNOWN: "Unknown", PR_UNPAID: "Pending", PR_PAID: "Paid", PR_EXPIRED: "Expired", PR_UNCONFIRMED: "Unconfirmed", } out["address"] = out.get("address").to_ui_string() out[f"amount ({XEC.ticker})"] = format_satoshis(out.get("amount")) out["status"] = pr_str[out.get("status", PR_UNKNOWN)] return out @command("w") def getrequest(self, key): """Return a payment request""" r = self.wallet.get_payment_request(Address.from_string(key), self.config) if not r: raise RuntimeError("Request not found") return self._format_request(r) # @command('w') # def ackrequest(self, serialized): # """""" # pass @command("w") def listrequests(self, pending=False, expired=False, paid=False): """List the payment requests you made.""" out = self.wallet.get_sorted_requests(self.config) if pending: f = PR_UNPAID elif expired: f = PR_EXPIRED elif paid: f = PR_PAID else: f = None if f is not None: out = list(filter(lambda x: x.get("status") == f, out)) return list(map(self._format_request, out)) @command("w") def createnewaddress(self): """Create a new receiving address, beyond the gap limit of the wallet""" return self.wallet.create_new_address(False).to_ui_string() @command("w") def getunusedaddress(self): """Returns the first unused address of the wallet, or None if all addresses are used. An address is considered as used if it has received a transaction, or if it is used in a payment request. """ return self.wallet.get_unused_address().to_ui_string() @command("w") def addrequest( self, amount, memo="", expiration=None, force=False, payment_url=None, index_url=None, ): """Create a payment request, using the first unused address of the wallet. The address will be condidered as used after this operation. If no payment is received, the address will be considered as unused if the payment request is deleted from the wallet.""" addr = self.wallet.get_unused_address() if addr is None: if not self.wallet.is_deterministic(): self.wallet.print_error( "Unable to find an unused address. Please use a deteministic " "wallet to proceed, then run with the --force option to create " "new addresses." ) return False if force: addr = self.wallet.create_new_address(False) else: self.wallet.print_error( "Unable to find an unused address. Try running with the --force " "option to create new addresses." ) return False amount = satoshis(amount) expiration = int(expiration) if expiration else None req = self.wallet.make_payment_request( addr, amount, memo, expiration, payment_url=payment_url, index_url=index_url ) self.wallet.add_payment_request(req, self.config) out = self.wallet.get_payment_request(addr, self.config) return self._format_request(out) @command("wp") def signrequest(self, address, password=None): "Sign payment request with an OpenAlias" alias = self.config.get("alias") if not alias: raise ValueError("No alias in your configuration") data = self.wallet.contacts.resolve(alias) alias_addr = (data and data.get("address")) or None if not alias_addr: raise RuntimeError("Alias could not be resolved") self.wallet.sign_payment_request(address, alias, alias_addr, password) @command("w") def rmrequest(self, address): """Remove a payment request""" return self.wallet.remove_payment_request(address, self.config) @command("w") def clearrequests(self): """Remove all payment requests""" for k in list(self.wallet.receive_requests.keys()): self.wallet.remove_payment_request(k, self.config) @command("n") def notify(self, address, URL): """Watch an address. Everytime the address changes, a http POST is sent to the URL.""" def callback(x): import urllib.request headers = {"content-type": "application/json"} data = {"address": address, "status": x.get("result")} serialized_data = util.to_bytes(json.dumps(data)) try: req = urllib.request.Request(URL, serialized_data, headers) urllib.request.urlopen(req, timeout=5) print_error("Got Response for %s" % address) except Exception as e: print_error(str(e)) h = Address.from_string(address).to_scripthash_hex() self.network.send([("blockchain.scripthash.subscribe", [h])], callback) return True @command("wn") def is_synchronized(self): """return wallet synchronization status""" return self.wallet.is_up_to_date() @command("n") def getfeerate(self): """Return current optimal fee rate per kilobyte, according to config settings (static/dynamic)""" return self.config.fee_per_kb() @command("") def help(self): # for the python console return sorted(known_commands.keys()) param_descriptions = { "wallet_path": "Wallet path(create/restore commands)", "privkey": "Private key. Type '?' to get a prompt.", "destination": "eCash address, contact or alias", "address": "eCash address", "seed": "Seed phrase", "txid": "Transaction ID", "pos": "Position", "height": "Block height", "tx": "Serialized transaction (hexadecimal)", "key": "Variable name", "pubkey": "Public key", "message": "Clear text message. Use quotes if it contains spaces.", "encrypted": "Encrypted message", "amount": ( f"Amount to be sent (in {XEC.ticker}). Type '!' to send the maximum available." ), "requested_amount": f"Requested amount (in {XEC.ticker}).", "outputs": 'list of ["address", amount]', "redeem_script": "redeem script (hexadecimal)", } command_options = { "addtransaction": ( None, ( "Whether transaction is to be used for broadcasting afterwards. Adds" " transaction to the wallet" ), ), "balance": ("-b", "Show the balances of listed addresses"), "change": (None, "Show only change addresses"), "change_addr": ( "-c", ( "Change address. Default is a spare address, or the source address if it's" " not in the wallet" ), ), "domain": ("-D", "List of addresses"), "encrypt_file": ( None, "Whether the file on disk should be encrypted with the provided password", ), "entropy": (None, "Custom entropy"), "expiration": (None, "Time in seconds"), "expired": (None, "Show only expired requests."), "fee": ("-f", f"Transaction fee (absolute, in {XEC.ticker})"), "feerate": (None, "Transaction fee rate (in sat/byte)"), "force": ( None, "Create new address beyond gap limit, if no more addresses are available.", ), "from_addr": ( "-F", ( "Source address (must be a wallet address; use sweep to spend from" " non-wallet address)." ), ), "frozen": (None, "Show only frozen addresses"), "funded": (None, "Show only funded addresses"), "imax": (None, "Maximum number of inputs"), "index_url": ( None, ( "Override the URL where you would like users to be shown the BIP70 Payment" " Request" ), ), "labels": ("-l", "Show the labels of listed addresses"), "language": ("-L", "Default language for wordlist"), "locktime": (None, "Set locktime block number"), "memo": ("-m", "Description of the request"), "nbits": (None, "Number of bits of entropy"), "new_password": (None, "New Password"), "nocheck": (None, "Do not verify aliases"), "op_return": ( None, "Specify string data to add to the transaction as an OP_RETURN output", ), "op_return_raw": ( None, ( "Specify raw hex data to add to the transaction as an OP_RETURN output" " (0x6a aka the OP_RETURN byte will be auto-prepended for you so do not" " include it)" ), ), "paid": (None, "Show only paid requests."), "passphrase": (None, "Seed extension"), "password": ("-W", "Password"), "payment_url": ( None, "Optional URL where you would like users to POST the BIP70 Payment message", ), "pending": (None, "Show only pending requests."), "privkey": (None, "Private key. Set to '?' to get a prompt."), "receiving": (None, "Show only receiving addresses"), "seed_type": ( None, ( "The type of seed to create, currently: 'electrum' and 'bip39' is" " supported. Default 'bip39'." ), ), "show_addresses": (None, "Show input and output addresses"), "show_fiat": (None, "Show fiat value of transactions"), "timeout": ( None, ( "Timeout in seconds to wait for the overall operation to complete. Defaults" " to 30.0." ), ), "unsigned": ("-u", "Do not sign transaction"), "unused": (None, "Show only unused addresses"), "use_net": ( None, ( "Go out to network for accurate fiat value and/or fee calculations for" " history. If not specified only the wallet's cache is used which may lead" " to inaccurate/missing fees and/or FX rates." ), ), "wallet_path": (None, "Wallet path(create/restore commands)"), "year": (None, "Show history for a given year"), } def json_loads(x): return json.loads(x, parse_float=lambda y: str(PyDecimal(y))) arg_types = { "num": int, "nbits": int, "imax": int, "year": int, "entropy": int, "tx": tx_from_str, "pubkeys": json_loads, "jsontx": json_loads, "inputs": json_loads, "outputs": json_loads, "fee": lambda x: str(PyDecimal(x)) if x is not None else None, "amount": lambda x: str(PyDecimal(x)) if x != "!" else "!", "locktime": int, } config_variables = { "addrequest": { "requests_dir": "directory where a bip70 file will be written.", "ssl_privkey": "Path to your SSL private key, needed to sign the request.", "ssl_chain": ( "Chain of SSL certificates, needed for signed requests. Put your" " certificate at the top and the root CA at the end" ), "url_rewrite": ( "Parameters passed to str.replace(), in order to create the r= part of" " ecash: URIs. Example:" " \"('file:///var/www/','https://electron-cash.org/')\"" ), }, "listrequests": { "url_rewrite": ( "Parameters passed to str.replace(), in order to create the r= part of" " ecash: URIs. Example:" " \"('file:///var/www/','https://electron-cash.org/')\"" ), }, } def add_network_options(parser): parser.add_argument( "-1", "--oneserver", action="store_true", dest="oneserver", default=False, help="connect to one server only", ) parser.add_argument( "-s", "--server", dest="server", default=None, help=( "set server host:port:protocol, where protocol is either t (tcp) or s (ssl)" ), ) parser.add_argument( "-p", "--proxy", dest="proxy", default=None, help="set proxy [type:]host[:port], where type is socks4,socks5 or http", ) parser.add_argument( "-x", "--disable_preferred_servers_only", action="store_false", dest="whitelist_servers_only", default=None, help=( "Disables 'preferred servers only' for this session. This must be used in" " conjunction with --server or --oneserver for them to work if they are" " outside the whitelist in servers.json (or the user-specified whitelist)." ), ) def add_global_options(parser): group = parser.add_argument_group("global options") group.add_argument( "-v", "--verbose", action="store_true", dest="verbose", default=False, help="Show debugging information", ) group.add_argument( "-D", "--dir", dest="data_path", help=f"{PROJECT_NAME} directory" ) group.add_argument( "-P", "--portable", action="store_true", dest="portable", default=False, help="Use local 'electrum_abc_data' directory", ) group.add_argument( "-w", "--wallet", dest="wallet_path", help="wallet path", type=os.path.abspath ) group.add_argument( "-wp", "--walletpassword", dest="wallet_password", default=None, help="Supply wallet password", ) group.add_argument( "--forgetconfig", action="store_true", dest="forget_config", default=False, help="Forget config on exit", ) group.add_argument( "--testnet", action="store_true", dest="testnet", default=False, help="Use Testnet", ) group.add_argument( "--regtest", action="store_true", dest="regtest", default=False, help="Use Regtest", ) def get_parser(): # create main parser parser = argparse.ArgumentParser( epilog=f"Run '{SCRIPT_NAME} help ' to see the help for a command" ) add_global_options(parser) subparsers = parser.add_subparsers(dest="cmd", metavar="") # gui parser_gui = subparsers.add_parser( "gui", description=f"Run {PROJECT_NAME}'s Graphical User Interface.", help="Run GUI (default)", ) parser_gui.add_argument( "url", nargs="?", default=None, help="bitcoin URI (or bip70 file)" ) parser_gui.add_argument( "-g", "--gui", dest="gui", help="select graphical user interface", choices=["qt", "text", "stdio"], ) parser_gui.add_argument( "-o", "--offline", action="store_true", dest="offline", default=False, help="Run offline", ) parser_gui.add_argument( "-m", action="store_true", dest="hide_gui", default=False, help="hide GUI on startup", ) parser_gui.add_argument( "-L", "--lang", dest="language", default=None, help="default language used in GUI", ) if sys.platform in ("windows", "win32"): # Hack to support forcing QT_OPENGL env var. See #1255. This allows us # to perhaps add a custom installer shortcut to force software rendering parser_gui.add_argument( "-O", "--qt_opengl", dest="qt_opengl", default=None, help=( "(Windows only) If using Qt gui, override the QT_OPENGL env-var with" " this value (angle,software,desktop are possible overrides)" ), ) if sys.platform not in ("darwin",): # Qt High DPI scaling can not be disabled on macOS since it is never # explicitly enabled on macOS! (see gui/qt/__init__.py) parser_gui.add_argument( "--qt_disable_highdpi", action="store_true", dest="qt_disable_highdpi", default=None, help="(Linux & Windows only) If using Qt gui, disable high DPI scaling", ) add_network_options(parser_gui) # daemon parser_daemon = subparsers.add_parser("daemon", help="Run Daemon") parser_daemon.add_argument( "subcommand", nargs="?", help=( "start, stop, status, load_wallet, close_wallet. Other commands may be" " added by plugins." ), ) parser_daemon.add_argument( "subargs", nargs="*", metavar="arg", help="additional arguments (used by plugins)", ) # parser_daemon.set_defaults(func=run_daemon) add_network_options(parser_daemon) # commands for cmdname in sorted(known_commands.keys()): cmd = known_commands[cmdname] p = subparsers.add_parser(cmdname, help=cmd.help, description=cmd.description) if cmdname == "restore": p.add_argument( "-o", "--offline", action="store_true", dest="offline", default=False, help="Run offline", ) for optname, default in zip(cmd.options, cmd.defaults): - a, help = command_options[optname] + a, help_ = command_options[optname] b = "--" + optname action = "store_true" if type(default) is bool else "store" args = (a, b) if a else (b,) if action == "store": _type = arg_types.get(optname, str) p.add_argument( *args, dest=optname, action=action, default=default, - help=help, + help=help_, type=_type, ) else: p.add_argument( - *args, dest=optname, action=action, default=default, help=help + *args, dest=optname, action=action, default=default, help=help_ ) for param in cmd.params: h = param_descriptions.get(param, "") _type = arg_types.get(param, str) p.add_argument(param, help=h, type=_type) cvh = config_variables.get(cmdname) if cvh: group = p.add_argument_group( "configuration variables", "(set with setconfig/getconfig)" ) for k, v in cvh.items(): group.add_argument(k, nargs="?", help=v) return parser diff --git a/electrum/electrumabc/dnssec.py b/electrum/electrumabc/dnssec.py index 6a873d5e1..a8c7e1383 100644 --- a/electrum/electrumabc/dnssec.py +++ b/electrum/electrumabc/dnssec.py @@ -1,318 +1,177 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC 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. # Check DNSSEC trust chain. # Todo: verify expiration dates # # Based on # http://backreference.org/2010/11/17/dnssec-verification-with-dig/ # https://github.com/rthalley/dnspython/blob/master/tests/test_dnssec.py import struct import time import dns.dnssec import dns.message import dns.name import dns.query import dns.rdatatype import dns.rdtypes.ANY.CNAME import dns.rdtypes.ANY.DLV import dns.rdtypes.ANY.DNSKEY import dns.rdtypes.ANY.DS import dns.rdtypes.ANY.NS import dns.rdtypes.ANY.NSEC import dns.rdtypes.ANY.NSEC3 import dns.rdtypes.ANY.NSEC3PARAM import dns.rdtypes.ANY.RRSIG import dns.rdtypes.ANY.SOA import dns.rdtypes.ANY.TXT import dns.rdtypes.IN.A import dns.rdtypes.IN.AAAA import dns.resolver -if not hasattr(dns, "version"): - # Do some monkey patching needed for versions of dnspython < 2 - - # Pure-Python version of dns.dnssec._validate_rsig - import hashlib - - import ecdsa - - from . import rsakey - - def python_validate_rrsig(rrset, rrsig, keys, origin=None, now=None): - from dns.dnssec import ( # pylint: disable=no-name-in-module - ECDSAP256SHA256, - ECDSAP384SHA384, - ValidationFailure, - _find_candidate_keys, - _is_ecdsa, - _is_rsa, - _make_algorithm_id, - _make_hash, - _to_rdata, - ) - - if isinstance(origin, str): - origin = dns.name.from_text(origin, dns.name.root) - - for candidate_key in _find_candidate_keys(keys, rrsig): - if not candidate_key: - raise ValidationFailure("unknown key") - - # For convenience, allow the rrset to be specified as a (name, rdataset) - # tuple as well as a proper rrset - if isinstance(rrset, tuple): - rrname = rrset[0] - rdataset = rrset[1] - else: - rrname = rrset.name - rdataset = rrset - - if now is None: - now = time.time() - if rrsig.expiration < now: - raise ValidationFailure("expired") - if rrsig.inception > now: - raise ValidationFailure("not yet valid") - - hash = _make_hash(rrsig.algorithm) - - if _is_rsa(rrsig.algorithm): - keyptr = candidate_key.key - (bytes,) = struct.unpack("!B", keyptr[0:1]) - keyptr = keyptr[1:] - if bytes == 0: - (bytes,) = struct.unpack("!H", keyptr[0:2]) - keyptr = keyptr[2:] - rsa_e = keyptr[0:bytes] - rsa_n = keyptr[bytes:] - n = ecdsa.util.string_to_number(rsa_n) - e = ecdsa.util.string_to_number(rsa_e) - pubkey = rsakey.RSAKey(n, e) - sig = rrsig.signature - - elif _is_ecdsa(rrsig.algorithm): - if rrsig.algorithm == ECDSAP256SHA256: - curve = ecdsa.curves.NIST256p - key_len = 32 - elif rrsig.algorithm == ECDSAP384SHA384: - curve = ecdsa.curves.NIST384p - key_len = 48 - else: - # shouldn't happen - raise ValidationFailure("unknown ECDSA curve") - keyptr = candidate_key.key - x = ecdsa.util.string_to_number(keyptr[0:key_len]) - y = ecdsa.util.string_to_number(keyptr[key_len : key_len * 2]) - assert ecdsa.ecdsa.point_is_valid(curve.generator, x, y) - point = ecdsa.ellipticcurve.Point(curve.curve, x, y, curve.order) - verifying_key = ecdsa.keys.VerifyingKey.from_public_point(point, curve) - r = rrsig.signature[:key_len] - s = rrsig.signature[key_len:] - sig = ecdsa.ecdsa.Signature( - ecdsa.util.string_to_number(r), ecdsa.util.string_to_number(s) - ) - - else: - raise ValidationFailure("unknown algorithm %u" % rrsig.algorithm) - - hash.update(_to_rdata(rrsig, origin)[:18]) - hash.update(rrsig.signer.to_digestable(origin)) - - if rrsig.labels < len(rrname) - 1: - suffix = rrname.split(rrsig.labels + 1)[1] - rrname = dns.name.from_text("*", suffix) - rrnamebuf = rrname.to_digestable(origin) - rrfixed = struct.pack( - "!HHI", rdataset.rdtype, rdataset.rdclass, rrsig.original_ttl - ) - rrlist = sorted(rdataset) - for rr in rrlist: - hash.update(rrnamebuf) - hash.update(rrfixed) - rrdata = rr.to_digestable(origin) - rrlen = struct.pack("!H", len(rrdata)) - hash.update(rrlen) - hash.update(rrdata) - - digest = hash.digest() - - if _is_rsa(rrsig.algorithm): - digest = _make_algorithm_id(rrsig.algorithm) + digest - if pubkey.verify(bytearray(sig), bytearray(digest)): - return - - elif _is_ecdsa(rrsig.algorithm): - diglong = ecdsa.util.string_to_number(digest) - if verifying_key.pubkey.verifies(diglong, sig): - return - - else: - raise ValidationFailure("unknown algorithm %s" % rrsig.algorithm) - - raise ValidationFailure("verify failure") - - class PyCryptodomexHashAlike: - def __init__(self, hashlib_func): - self._hash = hashlib_func - - def new(self): - return self._hash() - - # replace validate_rrsig - dns.dnssec._validate_rrsig = python_validate_rrsig - dns.dnssec.validate_rrsig = python_validate_rrsig - dns.dnssec.validate = dns.dnssec._validate - dns.dnssec._have_ecdsa = True - dns.dnssec.MD5 = PyCryptodomexHashAlike(hashlib.md5) - dns.dnssec.SHA1 = PyCryptodomexHashAlike(hashlib.sha1) - dns.dnssec.SHA256 = PyCryptodomexHashAlike(hashlib.sha256) - dns.dnssec.SHA384 = PyCryptodomexHashAlike(hashlib.sha384) - dns.dnssec.SHA512 = PyCryptodomexHashAlike(hashlib.sha512) - from .printerror import print_error # hard-coded trust anchors (root KSKs) trust_anchors = [ # KSK-2017: dns.rrset.from_text( ".", 1, "IN", "DNSKEY", ( "257 3 8" " AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=" ), ), # KSK-2010: dns.rrset.from_text( ".", 15202, "IN", "DNSKEY", ( "257 3 8 AwEAAagAIKlVZrpC6Ia7gEzahOR+9W29euxhJhVVLOyQbSEW0O8gcCjF" " FVQUTf6v58fLjwBd0YI0EzrAcQqBGCzh/RStIoO8g0NfnfL2MTJRkxoX" " bfDaUeVPQuYEhg37NZWAJQ9VnMVDxP/VHL496M/QZxkjf5/Efucp2gaD" " X6RS6CXpoY68LsvPVjR0ZSwzz1apAzvN9dlzEheX7ICJBBtuA6G3LQpz" " W5hOA2hzCTMjJPJ8LbqF6dsV6DoBQzgul0sGIcGOYl7OyQdXfZ57relS" " Qageu+ipAdTTJ25AsRTAoub8ONGcLmqrAmRLKBP1dfwhYB4N7knNnulq QxA+Uk1ihz0=" ), ), ] def check_query(ns, sub, _type, keys): q = dns.message.make_query(sub, _type, want_dnssec=True) response = dns.query.tcp(q, ns, timeout=5) assert response.rcode() == 0, "No answer" answer = response.answer assert len(answer) != 0, ("No DNS record found", sub, _type) assert len(answer) != 1, ("No DNSSEC record found", sub, _type) if answer[0].rdtype == dns.rdatatype.RRSIG: rrsig, rrset = answer elif answer[1].rdtype == dns.rdatatype.RRSIG: rrset, rrsig = answer else: raise RuntimeError("No signature set in record") if keys is None: keys = {dns.name.from_text(sub): rrset} dns.dnssec.validate(rrset, rrsig, keys) return rrset def get_and_validate(ns, url, _type): # get trusted root key root_rrset = None for dnskey_rr in trust_anchors: try: # Check if there is a valid signature for the root dnskey root_rrset = check_query( ns, "", dns.rdatatype.DNSKEY, {dns.name.root: dnskey_rr} ) break except dns.dnssec.ValidationFailure: # It's OK as long as one key validates continue if not root_rrset: raise dns.dnssec.ValidationFailure("None of the trust anchors found in DNS") keys = {dns.name.root: root_rrset} # top-down verification parts = url.split(".") for i in range(len(parts), 0, -1): sub = ".".join(parts[i - 1 :]) name = dns.name.from_text(sub) # If server is authoritative, don't fetch DNSKEY query = dns.message.make_query(sub, dns.rdatatype.NS) response = dns.query.udp(query, ns, 3) assert response.rcode() == dns.rcode.NOERROR, "query error" rrset = ( response.authority[0] if len(response.authority) > 0 else response.answer[0] ) rr = rrset[0] if rr.rdtype == dns.rdatatype.SOA: continue # get DNSKEY (self-signed) rrset = check_query(ns, sub, dns.rdatatype.DNSKEY, None) # get DS (signed by parent) ds_rrset = check_query(ns, sub, dns.rdatatype.DS, keys) # verify that a signed DS validates DNSKEY for ds in ds_rrset: for dnskey in rrset: htype = "SHA256" if ds.digest_type == 2 else "SHA1" good_ds = dns.dnssec.make_ds(name, dnskey, htype) if ds == good_ds: break else: continue break else: raise RuntimeError("DS does not match DNSKEY") # set key for next iteration keys = {name: rrset} # get TXT record (signed by zone) rrset = check_query(ns, url, _type, keys) return rrset def query(url, rtype): # 8.8.8.8 is Google's public DNS server nameservers = ["8.8.8.8"] ns = nameservers[0] try: out = get_and_validate(ns, url, rtype) validated = True except Exception as e: # traceback.print_exc(file=sys.stderr) print_error("DNSSEC error:", str(e)) resolver = dns.resolver.get_default_resolver() out = resolver.query(url, rtype) validated = False return out, validated diff --git a/electrum/electrumabc/ecc_fast.py b/electrum/electrumabc/ecc_fast.py index 241b58164..2a67cd898 100644 --- a/electrum/electrumabc/ecc_fast.py +++ b/electrum/electrumabc/ecc_fast.py @@ -1,180 +1,180 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -*- mode: python3 -*- # taken (with minor modifications) from pycoin # https://github.com/richardkiss/pycoin/blob/01b1787ed902df23f99a55deb00d8cd076a906fe/pycoin/ecdsa/native/secp256k1.py from ctypes import byref, c_size_t, create_string_buffer import ecdsa from . import secp256k1 from .printerror import print_msg class _patched_functions: prepared_to_patch = False monkey_patching_active = False def _prepare_monkey_patching_of_python_ecdsa_internals_with_libsecp256k1(): if not secp256k1.secp256k1: return # save original functions so that we can undo patching (needed for tests) _patched_functions.orig_sign = staticmethod(ecdsa.ecdsa.Private_key.sign) _patched_functions.orig_verify = staticmethod(ecdsa.ecdsa.Public_key.verifies) _patched_functions.orig_mul = staticmethod(ecdsa.ellipticcurve.Point.__mul__) curve_secp256k1 = ecdsa.ecdsa.curve_secp256k1 curve_order = ecdsa.curves.SECP256k1.order point_at_infinity = ecdsa.ellipticcurve.INFINITY def mul(self: ecdsa.ellipticcurve.Point, other: int): if self.curve() != curve_secp256k1: # this operation is not on the secp256k1 curve; use original implementation return _patched_functions.orig_mul(self, other) other %= curve_order if self == point_at_infinity or other == 0: return point_at_infinity pubkey = create_string_buffer(64) public_pair_bytes = ( b"\4" + int(self.x()).to_bytes(32, byteorder="big") + int(self.y()).to_bytes(32, byteorder="big") ) r = secp256k1.secp256k1.secp256k1_ec_pubkey_parse( secp256k1.secp256k1.ctx, pubkey, public_pair_bytes, len(public_pair_bytes) ) if not r: return False r = secp256k1.secp256k1.secp256k1_ec_pubkey_tweak_mul( secp256k1.secp256k1.ctx, pubkey, int(other).to_bytes(32, byteorder="big") ) if not r: return point_at_infinity pubkey_serialized = create_string_buffer(65) pubkey_size = c_size_t(65) secp256k1.secp256k1.secp256k1_ec_pubkey_serialize( secp256k1.secp256k1.ctx, pubkey_serialized, byref(pubkey_size), pubkey, secp256k1.SECP256K1_EC_UNCOMPRESSED, ) x = int.from_bytes(pubkey_serialized[1:33], byteorder="big") y = int.from_bytes(pubkey_serialized[33:], byteorder="big") return ecdsa.ellipticcurve.Point(curve_secp256k1, x, y, curve_order) - def sign(self: ecdsa.ecdsa.Private_key, hash: int, random_k: int): + def sign(self: ecdsa.ecdsa.Private_key, hash_: int, random_k: int): # note: random_k is ignored if self.public_key.curve != curve_secp256k1: # this operation is not on the secp256k1 curve; use original implementation - return _patched_functions.orig_sign(self, hash, random_k) + return _patched_functions.orig_sign(self, hash_, random_k) # might not be int but might rather be gmpy2 'mpz' type - maybe_mpz = type(hash) + maybe_mpz = type(hash_) secret_exponent = self.secret_multiplier nonce_function = None sig = create_string_buffer(64) - sig_hash_bytes = int(hash).to_bytes(32, byteorder="big") + sig_hash_bytes = int(hash_).to_bytes(32, byteorder="big") secp256k1.secp256k1.secp256k1_ecdsa_sign( secp256k1.secp256k1.ctx, sig, sig_hash_bytes, int(secret_exponent).to_bytes(32, byteorder="big"), nonce_function, None, ) compact_signature = create_string_buffer(64) secp256k1.secp256k1.secp256k1_ecdsa_signature_serialize_compact( secp256k1.secp256k1.ctx, compact_signature, sig ) r = int.from_bytes(compact_signature[:32], byteorder="big") s = int.from_bytes(compact_signature[32:], byteorder="big") return ecdsa.ecdsa.Signature(maybe_mpz(r), maybe_mpz(s)) def verify( - self: ecdsa.ecdsa.Public_key, hash: int, signature: ecdsa.ecdsa.Signature + self: ecdsa.ecdsa.Public_key, hash_: int, signature: ecdsa.ecdsa.Signature ): if self.curve != curve_secp256k1: # this operation is not on the secp256k1 curve; use original implementation - return _patched_functions.orig_verify(self, hash, signature) + return _patched_functions.orig_verify(self, hash_, signature) sig = create_string_buffer(64) input64 = int(signature.r).to_bytes(32, byteorder="big") + int( signature.s ).to_bytes(32, byteorder="big") r = secp256k1.secp256k1.secp256k1_ecdsa_signature_parse_compact( secp256k1.secp256k1.ctx, sig, input64 ) if not r: return False r = secp256k1.secp256k1.secp256k1_ecdsa_signature_normalize( secp256k1.secp256k1.ctx, sig, sig ) public_pair_bytes = ( b"\4" + int(self.point.x()).to_bytes(32, byteorder="big") + int(self.point.y()).to_bytes(32, byteorder="big") ) pubkey = create_string_buffer(64) r = secp256k1.secp256k1.secp256k1_ec_pubkey_parse( secp256k1.secp256k1.ctx, pubkey, public_pair_bytes, len(public_pair_bytes) ) if not r: return False return 1 == secp256k1.secp256k1.secp256k1_ecdsa_verify( secp256k1.secp256k1.ctx, sig, - int(hash).to_bytes(32, byteorder="big"), + int(hash_).to_bytes(32, byteorder="big"), pubkey, ) # save new functions so that we can (re-)do patching _patched_functions.fast_sign = sign _patched_functions.fast_verify = verify _patched_functions.fast_mul = mul _patched_functions.prepared_to_patch = True def do_monkey_patching_of_python_ecdsa_internals_with_libsecp256k1(): if not secp256k1.secp256k1: print_msg( "[ecc] info: libsecp256k1 library not available, falling back to" " python-ecdsa. This means signing operations will be slower. Try" " running:\n\n $ contrib/make_secp\n\n(You need to be running from the" " git sources for contrib/make_secp to be available)" ) return if not _patched_functions.prepared_to_patch: raise Exception("can't patch python-ecdsa without preparations") ecdsa.ecdsa.Private_key.sign = _patched_functions.fast_sign ecdsa.ecdsa.Public_key.verifies = _patched_functions.fast_verify ecdsa.ellipticcurve.Point.__mul__ = _patched_functions.fast_mul # ecdsa.ellipticcurve.Point.__add__ = ... # TODO?? _patched_functions.monkey_patching_active = True # print_error('[ecc] info: libsecp256k1 library found and will be used for ecdsa signing operations.') def undo_monkey_patching_of_python_ecdsa_internals_with_libsecp256k1(): if not secp256k1.secp256k1: return if not _patched_functions.prepared_to_patch: raise Exception("can't patch python-ecdsa without preparations") ecdsa.ecdsa.Private_key.sign = _patched_functions.orig_sign ecdsa.ecdsa.Public_key.verifies = _patched_functions.orig_verify ecdsa.ellipticcurve.Point.__mul__ = _patched_functions.orig_mul _patched_functions.monkey_patching_active = False def is_using_fast_ecc(): return _patched_functions.monkey_patching_active _prepare_monkey_patching_of_python_ecdsa_internals_with_libsecp256k1() diff --git a/electrum/electrumabc/interface.py b/electrum/electrumabc/interface.py index 1025dbfc8..c35be5da3 100644 --- a/electrum/electrumabc/interface.py +++ b/electrum/electrumabc/interface.py @@ -1,587 +1,589 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2011 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. import os import re import socket import ssl import sys import threading import time import traceback from collections import namedtuple from typing import Optional, Tuple import requests from pathvalidate import sanitize_filename from . import pem, util, x509 from .printerror import PrintError, is_verbose, print_error, print_msg from .utils import Event ca_path = requests.certs.where() PING_INTERVAL = 300 def Connection(server, queue, config_path, callback=None): """Makes asynchronous connections to a remote electrum server. Returns the running thread that is making the connection. Once the thread has connected, it finishes, placing a tuple on the queue of the form (server, socket), where socket is None if connection failed. """ host, port, protocol = server.rsplit(":", 2) if protocol not in "st": raise Exception("Unknown protocol: %s" % protocol) c = TcpConnection(server, queue, config_path) if callback: callback(c) c.start() return c class TcpConnection(threading.Thread, PrintError): bad_certificate = Event() def __init__(self, server, queue, config_path): threading.Thread.__init__(self) self.config_path = config_path self.queue = queue self.server = server self.host, self.port, self.protocol = self.server.rsplit(":", 2) self.host = str(self.host) self.port = int(self.port) self.use_ssl = self.protocol == "s" self.daemon = True def diagnostic_name(self): return self.host def check_host_name(self, peercert, name) -> bool: """Wrapper for ssl.match_hostname that never throws. Returns True if the certificate matches, False otherwise. Supports whatever wildcard certs and other bells and whistles supported by ssl.match_hostname.""" # Check that the peer has supplied a certificate. # None/{} is not acceptable. if not peercert: return False try: ssl.match_hostname(peercert, name) return True except ssl.CertificateError as e: self.print_error("SSL certificate hostname mismatch:", str(e)) return False def get_simple_socket(self): try: addr_info = socket.getaddrinfo( self.host, self.port, socket.AF_UNSPEC, socket.SOCK_STREAM ) except OverflowError: # This can happen if user specifies a huge port out of 32-bit range. See #985 self.print_error("port invalid:", self.port) return except socket.gaierror: self.print_error("cannot resolve hostname") return except UnicodeError: self.print_error("hostname cannot be decoded with 'idna' codec") return e = None for res in addr_info: try: s = socket.socket(res[0], socket.SOCK_STREAM) s.settimeout(10) s.connect(res[4]) s.settimeout(2) s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) return s except Exception as _e: e = _e continue else: self.print_error("failed to connect", str(e)) @staticmethod def get_ssl_context(cert_reqs, ca_certs): context = ssl.create_default_context( purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_certs ) context.check_hostname = False context.verify_mode = cert_reqs context.minimum_version = ssl.TLSVersion.TLSv1_2 return context def _get_socket_and_verify_ca_cert( self, *, suppress_errors ) -> Tuple[Optional[ssl.SSLSocket], bool]: """Attempts to connect to the remote host, assuming it is using a CA signed certificate. If the cert is valid then a tuple of: (wrapped SSLSocket, False) is returned. Otherwise (None, bool) is returned on error. If the second item in the tuple is True, then the entire operation should be aborted due to low-level error.""" s = self.get_simple_socket() if s is not None: try: context = self.get_ssl_context( cert_reqs=ssl.CERT_REQUIRED, ca_certs=ca_path ) s = context.wrap_socket(s, do_handshake_on_connect=True) # validate cert if s and self.check_host_name(s.getpeercert(), self.host): self.print_error("SSL certificate signed by CA") # it's good, return the wrapped socket return s, False # bad cert or other shenanigans, return None but inform caller # to try alternate "pinned self-signed cert" code path return None, False except ssl.SSLError as e: if not suppress_errors: # Only show error if no pinned self-signed cert exists self.print_error("SSL error:", e) return None, False # inform caller to continue trying alternate except Exception as e: self.print_error( "Unexpected exception in _get_socket_and_verify_ca_cert:", repr(e) ) return None, True # inform caller to abort the operation def get_socket(self): if self.use_ssl: # Try with CA first, since they are preferred over self-signed certs # and are always accepted (even if a previous pinned self-signed # cert exists). cert_path = os.path.join( self.config_path, "certs", sanitize_filename(self.host, replacement_text="_"), ) has_pinned_self_signed = os.path.exists(cert_path) s, give_up = self._get_socket_and_verify_ca_cert( suppress_errors=has_pinned_self_signed ) if s: if has_pinned_self_signed: # Delete pinned cert. They now have a valid CA-signed cert. # This hopefully undoes the bug in previous EC versions that # refused to consider CA-signed certs at all if the server # ever had a self-signed cert in the past. try: os.remove(cert_path) self.print_error( ( "Server is now using a CA-signed certificate, deleted" " previous self-signed certificate:" ), cert_path, ) except OSError: pass return s elif give_up: # low-level error in _get_socket_and_verify_ca_cert, give up return # if we get here, certificate is not CA signed, so try the alternate # "pinned self-signed" method. if not has_pinned_self_signed: is_new = True # get server certificate. Do not use ssl.get_server_certificate # because it does not work with proxy s = self.get_simple_socket() if s is None: return try: context = self.get_ssl_context( cert_reqs=ssl.CERT_NONE, ca_certs=None ) s = context.wrap_socket(s) except ssl.SSLError as e: self.print_error("SSL error retrieving SSL certificate:", e) return except Exception: return dercert = s.getpeercert(True) s.close() cert = ssl.DER_cert_to_PEM_cert(dercert) # workaround android bug cert = re.sub( "([^\n])-----END CERTIFICATE-----", "\\1\n-----END CERTIFICATE-----", cert, ) temporary_path = cert_path + ".temp" util.assert_datadir_available(self.config_path) with open(temporary_path, "w", encoding="utf-8") as f: f.write(cert) f.flush() os.fsync(f.fileno()) else: is_new = False temporary_path = None s = self.get_simple_socket() if s is None: return if self.use_ssl: try: context = self.get_ssl_context( cert_reqs=ssl.CERT_REQUIRED, ca_certs=(temporary_path if is_new else cert_path), ) s = context.wrap_socket(s, do_handshake_on_connect=True) except socket.timeout: self.print_error("timeout") return except ssl.SSLError as e: self.print_error("SSL error:", e) if e.errno != 1: return if is_new: rej = cert_path + ".rej" try: if os.path.exists(rej): os.unlink(rej) os.rename(temporary_path, rej) except OSError as e2: self.print_error( "Could not rename rejected certificate:", rej, repr(e2) ) else: util.assert_datadir_available(self.config_path) with open(cert_path, encoding="utf-8") as f: cert = f.read() try: b = pem.dePem(cert, "CERTIFICATE") x = x509.X509(b) except Exception: if is_verbose: self.print_error( "Error checking certificate, traceback follows" ) traceback.print_exc(file=sys.stderr) self.print_error("wrong certificate") self.bad_certificate(self.server, cert_path) return try: x.check_date() except Exception: self.print_error("certificate has expired:", cert_path) try: os.unlink(cert_path) self.print_error("Removed expired certificate:", cert_path) except OSError as e2: self.print_error( "Could not remove expired certificate:", cert_path, repr(e2), ) return self.print_error("wrong certificate") self.bad_certificate(self.server, cert_path) if e.errno == 104: return return if is_new: self.print_error("saving certificate") os.rename(temporary_path, cert_path) return s def run(self): try: socket = self.get_socket() except OSError: if is_verbose: self.print_error("Error getting socket, traceback follows") traceback.print_exc(file=sys.stderr) socket = None if socket: self.print_error("connected") self.queue.put((self.server, socket)) class Interface(PrintError): """The Interface class handles a socket connected to a single remote electrum server. It's exposed API is: - Member functions close(), fileno(), get_responses(), has_timed_out(), ping_required(), queue_request(), send_requests() - Member variable server. """ MODE_DEFAULT = "default" MODE_BACKWARD = "backward" MODE_BINARY = "binary" MODE_CATCH_UP = "catch_up" MODE_VERIFICATION = "verification" def __init__(self, server, socket, *, max_message_bytes=0, config=None): self.server = server self.config = config self.host, self.port, _ = server.rsplit(":", 2) self.socket = socket self.pipe = util.JSONSocketPipe(socket, max_message_bytes=max_message_bytes) # Dump network messages. Set at runtime from the console. self.debug = False self.request_time = time.time() self.unsent_requests = [] self.unanswered_requests = {} self.last_send = time.time() self.mode = None def __repr__(self): return "<{}.{} {}>".format(__name__, type(self).__name__, self.format_address()) def format_address(self): return "{}:{}".format(self.host, self.port) def set_mode(self, mode): self.print_error("set_mode({})".format(mode)) self.mode = mode def diagnostic_name(self): return self.host def fileno(self): # Needed for select return self.socket.fileno() def close(self): try: self.socket.shutdown(socket.SHUT_RDWR) except Exception: pass try: self.socket.close() except Exception: pass def queue_request(self, *args): # method, params, _id """Queue a request, later to be sent with send_requests when the socket is available for writing.""" self.request_time = time.time() self.unsent_requests.append(args) ReqThrottleParams = namedtuple("ReqThrottleParams", "max chunkSize") req_throttle_default = ReqThrottleParams(2000, 100) @classmethod def get_req_throttle_params(cls, config): tup = config and config.get("network_unanswered_requests_throttle") if not isinstance(tup, (list, tuple)) or len(tup) != 2: tup = cls.req_throttle_default tup = cls.ReqThrottleParams(*tup) return tup @classmethod - def set_req_throttle_params(cls, config, max=None, chunkSize=None): + def set_req_throttle_params( + cls, config, max_unanswered_requests=None, chunkSize=None + ): if not config: return l_ = list(cls.get_req_throttle_params(config)) - if max is not None: - l_[0] = max + if max_unanswered_requests is not None: + l_[0] = max_unanswered_requests if chunkSize is not None: l_[1] = chunkSize config.set_key("network_unanswered_requests_throttle", l_) def num_requests(self): """If there are more than tup.max (default: 2000) unanswered requests, don't send any more. Otherwise send more requests, but not more than tup.chunkSize (default: 100) at a time.""" tup = self.get_req_throttle_params(self.config) if len(self.unanswered_requests) >= tup.max: return 0 return min(tup.chunkSize, len(self.unsent_requests)) def send_requests(self): """Sends queued requests. Returns False on failure.""" try: try: self.pipe.send_flush() except util.timeout: if self.debug: self.print_error( "still flushing send data... [{}]".format( len(self.pipe.send_buf) ) ) return True self.last_send = time.time() def make_dict(m, p, i): return {"method": m, "params": p, "id": i} n = self.num_requests() wire_requests = self.unsent_requests[0:n] self.pipe.send_all([make_dict(*r) for r in wire_requests]) except util.timeout: # this is OK, the send is in the pipe and we'll flush it out # eventually. pass except self.pipe.Closed as e: self.print_error(str(e)) return False except Exception: traceback.print_exc(file=sys.stderr) return False self.unsent_requests = self.unsent_requests[n:] for request in wire_requests: if self.debug: self.print_error("-->", request) self.unanswered_requests[request[2]] = request return True def ping_required(self): """Returns True if a ping should be sent.""" return time.time() - self.last_send > PING_INTERVAL def has_timed_out(self): """Returns True if the interface has timed out.""" if ( self.unanswered_requests and time.time() - self.request_time > 10 and self.pipe.idle_time() > 10 ): self.print_error("timeout", len(self.unanswered_requests)) return True return False def get_responses(self): """Call if there is data available on the socket. Returns a list of (request, response) pairs. Notifications are singleton unsolicited responses presumably as a result of prior subscriptions, so request is None and there is no 'id' member. Otherwise it is a response, which has an 'id' member and a corresponding request. If the connection was closed remotely or the remote server is misbehaving, a (None, None) will appear. """ responses = [] while True: response = None try: response = self.pipe.get() except util.timeout: break except self.pipe.Closed as e: self.print_error(str(e)) except Exception: traceback.print_exc(file=sys.stderr) if type(response) is not dict: # time to close this connection. if type(response) is not None: self.print_error( "received non-object type {}".format(type(response)) ) # signal that this connection is done. responses.append((None, None)) break if self.debug: self.print_error("<--", response) wire_id = response.get("id", None) if wire_id is None: # Notification # defend against funny/out-of-spec JSON if not isinstance(response.get("method"), str): # Malforned notification -- signal bad server self.print_error( "Server sent us a notification message without a 'method':", response, ) responses.append((None, None)) # Signal break # At this point the notification has a 'method' defined, so we know # it's good. responses.append((None, response)) else: request = self.unanswered_requests.pop(wire_id, None) if request: responses.append((request, response)) else: self.print_error("unknown wire ID", wire_id) responses.append((None, None)) # Signal break return responses def check_cert(host, cert): try: b = pem.dePem(cert, "CERTIFICATE") x = x509.X509(b) except Exception: if is_verbose: print_error("Error checking certificate, traceback follows") traceback.print_exc(file=sys.stderr) return try: x.check_date() expired = False except Exception: expired = True m = "host: %s\n" % host m += "has_expired: %s\n" % expired print_msg(m) # Used by tests def _match_hostname(name, val): if val == name: return True return val.startswith("*.") and name.endswith(val[1:]) def test_certificates(): from .simple_config import SimpleConfig config = SimpleConfig() mydir = os.path.join(config.path, "certs") certs = os.listdir(mydir) for c in certs: p = os.path.join(mydir, c) with open(p, encoding="utf-8") as f: cert = f.read() check_cert(c, cert) if __name__ == "__main__": test_certificates() diff --git a/electrum/electrumabc/pem.py b/electrum/electrumabc/pem.py index 0b118e00d..cbde66c4d 100644 --- a/electrum/electrumabc/pem.py +++ b/electrum/electrumabc/pem.py @@ -1,203 +1,203 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC 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. # This module uses code from TLSLlite # TLSLite Author: Trevor Perrin) import binascii from .x509 import ASN1Node, bytestr_to_int, decode_OID def a2b_base64(s): try: b = bytearray(binascii.a2b_base64(s)) except Exception as e: raise SyntaxError("base64 error: %s" % e) return b def b2a_base64(b): return binascii.b2a_base64(b) def dePem(s, name): """Decode a PEM string into a bytearray of its payload. The input must contain an appropriate PEM prefix and postfix based on the input name string, e.g. for name="CERTIFICATE": -----BEGIN CERTIFICATE----- MIIBXDCCAUSgAwIBAgIBADANBgkqhkiG9w0BAQUFADAPMQ0wCwYDVQQDEwRUQUNL ... KoZIhvcNAQEFBQADAwA5kw== -----END CERTIFICATE----- The first such PEM block in the input will be found, and its payload will be base64 decoded and returned. """ prefix = "-----BEGIN %s-----" % name postfix = "-----END %s-----" % name start = s.find(prefix) if start == -1: raise SyntaxError("Missing PEM prefix") end = s.find(postfix, start + len(prefix)) if end == -1: raise SyntaxError("Missing PEM postfix") s = s[start + len("-----BEGIN %s-----" % name) : end] retBytes = a2b_base64(s) # May raise SyntaxError return retBytes def dePemList(s, name): """Decode a sequence of PEM blocks into a list of bytearrays. The input must contain any number of PEM blocks, each with the appropriate PEM prefix and postfix based on the input name string, e.g. for name="TACK BREAK SIG". Arbitrary text can appear between and before and after the PEM blocks. For example: " Created by TACK.py 0.9.3 Created at 2012-02-01T00:30:10Z -----BEGIN TACK BREAK SIG----- ATKhrz5C6JHJW8BF5fLVrnQss6JnWVyEaC0p89LNhKPswvcC9/s6+vWLd9snYTUv YMEBdw69PUP8JB4AdqA3K6Ap0Fgd9SSTOECeAKOUAym8zcYaXUwpk0+WuPYa7Zmm SkbOlK4ywqt+amhWbg9txSGUwFO5tWUHT3QrnRlE/e3PeNFXLx5Bckg= -----END TACK BREAK SIG----- Created by TACK.py 0.9.3 Created at 2012-02-01T00:30:11Z -----BEGIN TACK BREAK SIG----- ATKhrz5C6JHJW8BF5fLVrnQss6JnWVyEaC0p89LNhKPswvcC9/s6+vWLd9snYTUv YMEBdw69PUP8JB4AdqA3K6BVCWfcjN36lx6JwxmZQncS6sww7DecFO/qjSePCxwM +kdDqX/9/183nmjx6bf0ewhPXkA0nVXsDYZaydN8rJU1GaMlnjcIYxY= -----END TACK BREAK SIG----- " All such PEM blocks will be found, decoded, and return in an ordered list of bytearrays, which may have zero elements if not PEM blocks are found. """ bList = [] prefix = "-----BEGIN %s-----" % name postfix = "-----END %s-----" % name while 1: start = s.find(prefix) if start == -1: return bList end = s.find(postfix, start + len(prefix)) if end == -1: raise SyntaxError("Missing PEM postfix") s2 = s[start + len(prefix) : end] retBytes = a2b_base64(s2) # May raise SyntaxError bList.append(retBytes) s = s[end + len(postfix) :] def pem(b, name): """Encode a payload bytearray into a PEM string. The input will be base64 encoded, then wrapped in a PEM prefix/postfix based on the name string, e.g. for name="CERTIFICATE": -----BEGIN CERTIFICATE----- MIIBXDCCAUSgAwIBAgIBADANBgkqhkiG9w0BAQUFADAPMQ0wCwYDVQQDEwRUQUNL ... KoZIhvcNAQEFBQADAwA5kw== -----END CERTIFICATE----- """ s1 = b2a_base64(b)[:-1] # remove terminating \n s2 = b"" while s1: s2 += s1[:64] + b"\n" s1 = s1[64:] s = ( ("-----BEGIN %s-----\n" % name).encode("ascii") + s2 + ("-----END %s-----\n" % name).encode("ascii") ) return s def pemSniff(inStr, name): searchStr = "-----BEGIN %s-----" % name return searchStr in inStr def parse_private_key(s): """Parse a string containing a PEM-encoded .""" if pemSniff(s, "PRIVATE KEY"): - bytes = dePem(s, "PRIVATE KEY") - return _parsePKCS8(bytes) + key = dePem(s, "PRIVATE KEY") + return _parsePKCS8(key) elif pemSniff(s, "RSA PRIVATE KEY"): - bytes = dePem(s, "RSA PRIVATE KEY") - return _parseSSLeay(bytes) + key = dePem(s, "RSA PRIVATE KEY") + return _parseSSLeay(key) else: raise SyntaxError("Not a PEM private key file") -def _parsePKCS8(_bytes): - s = ASN1Node(_bytes) +def _parsePKCS8(key): + s = ASN1Node(key) root = s.root() version_node = s.first_child(root) version = bytestr_to_int(s.get_value_of_type(version_node, "INTEGER")) if version != 0: raise SyntaxError("Unrecognized PKCS8 version") rsaOID_node = s.next_node(version_node) ii = s.first_child(rsaOID_node) rsaOID = decode_OID(s.get_value_of_type(ii, "OBJECT IDENTIFIER")) if rsaOID != "1.2.840.113549.1.1.1": raise SyntaxError("Unrecognized AlgorithmIdentifier") privkey_node = s.next_node(rsaOID_node) value = s.get_value_of_type(privkey_node, "OCTET STRING") return _parseASN1PrivateKey(value) -def _parseSSLeay(bytes): - return _parseASN1PrivateKey(ASN1Node(bytes)) +def _parseSSLeay(key): + return _parseASN1PrivateKey(ASN1Node(key)) def bytesToNumber(s): return int(binascii.hexlify(s), 16) def _parseASN1PrivateKey(s): s = ASN1Node(s) root = s.root() version_node = s.first_child(root) version = bytestr_to_int(s.get_value_of_type(version_node, "INTEGER")) if version != 0: raise SyntaxError("Unrecognized RSAPrivateKey version") n = s.next_node(version_node) e = s.next_node(n) d = s.next_node(e) p = s.next_node(d) q = s.next_node(p) dP = s.next_node(q) dQ = s.next_node(dP) qInv = s.next_node(dQ) return list( map( lambda x: bytesToNumber(s.get_value_of_type(x, "INTEGER")), [n, e, d, p, q, dP, dQ, qInv], ) ) diff --git a/electrum/electrumabc/rsakey.py b/electrum/electrumabc/rsakey.py index 48eb33b05..b2e8982cd 100644 --- a/electrum/electrumabc/rsakey.py +++ b/electrum/electrumabc/rsakey.py @@ -1,599 +1,580 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC 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. # This module uses functions from TLSLite (public domain) # # TLSLite Authors: # Trevor Perrin # Martin von Loewis - python 3 port # Yngve Pettersen (ported by Paul Sokolovsky) - TLS 1.2 # """Pure-Python RSA implementation.""" import hashlib import math import os import zlib def SHA1(x): return hashlib.sha1(x).digest() # ************************************************************************** # PRNG Functions # ************************************************************************** # Check that os.urandom works length = len(zlib.compress(os.urandom(1000))) assert length > 900 def getRandomBytes(howMany): b = bytearray(os.urandom(howMany)) assert len(b) == howMany return b prngName = "os.urandom" # ************************************************************************** # Converter Functions # ************************************************************************** def bytesToNumber(b): total = 0 multiplier = 1 for count in range(len(b) - 1, -1, -1): byte = b[count] total += multiplier * byte multiplier *= 256 return total def numberToByteArray(n, howManyBytes=None): """Convert an integer into a bytearray, zero-pad to howManyBytes. The returned bytearray may be smaller than howManyBytes, but will not be larger. The returned bytearray will contain a big-endian encoding of the input integer (n). """ if howManyBytes is None: howManyBytes = numBytes(n) b = bytearray(howManyBytes) for count in range(howManyBytes - 1, -1, -1): b[count] = int(n % 256) n >>= 8 return b def mpiToNumber(mpi): # mpi is an openssl-format bignum string if (ord(mpi[4]) & 0x80) != 0: # Make sure this is a positive number raise AssertionError() b = bytearray(mpi[4:]) return bytesToNumber(b) def numberToMPI(n): b = numberToByteArray(n) ext = 0 # If the high-order bit is going to be set, # add an extra byte of zeros if (numBits(n) & 0x7) == 0: ext = 1 length = numBytes(n) + ext b = bytearray(4 + ext) + b b[0] = (length >> 24) & 0xFF b[1] = (length >> 16) & 0xFF b[2] = (length >> 8) & 0xFF b[3] = length & 0xFF return bytes(b) # ************************************************************************** # Misc. Utility Functions # ************************************************************************** def numBits(n): if n == 0: return 0 s = "%x" % n return ((len(s) - 1) * 4) + { "0": 0, "1": 1, "2": 2, "3": 2, "4": 3, "5": 3, "6": 3, "7": 3, "8": 4, "9": 4, "a": 4, "b": 4, "c": 4, "d": 4, "e": 4, "f": 4, }[s[0]] def numBytes(n): if n == 0: return 0 bits = numBits(n) return int(math.ceil(bits / 8.0)) # ************************************************************************** # Big Number Math # ************************************************************************** def getRandomNumber(low, high): if low >= high: raise AssertionError() howManyBits = numBits(high) howManyBytes = numBytes(high) lastBits = howManyBits % 8 while 1: - bytes = getRandomBytes(howManyBytes) + data = getRandomBytes(howManyBytes) if lastBits: - bytes[0] = bytes[0] % (1 << lastBits) - n = bytesToNumber(bytes) + data[0] = data[0] % (1 << lastBits) + n = bytesToNumber(data) if n >= low and n < high: return n def gcd(a, b): a, b = max(a, b), min(a, b) while b: a, b = b, a % b return a def lcm(a, b): return (a * b) // gcd(a, b) # Returns inverse of a mod b, zero if none # Uses Extended Euclidean Algorithm def invMod(a, b): c, d = a, b uc, ud = 1, 0 while c != 0: q = d // c c, d = d - (q * c), c uc, ud = ud - (q * uc), uc if d == 1: return ud % b return 0 def powMod(base, power, modulus): if power < 0: result = pow(base, power * -1, modulus) result = invMod(result, modulus) return result else: return pow(base, power, modulus) # Pre-calculate a sieve of the ~100 primes < 1000: def makeSieve(n): sieve = list(range(n)) for count in range(2, int(math.sqrt(n)) + 1): if sieve[count] == 0: continue x = sieve[count] * 2 while x < len(sieve): sieve[x] = 0 x += sieve[count] sieve = [x for x in sieve[2:] if x] return sieve sieve = makeSieve(1000) def isPrime(n, iterations=5, display=False): # Trial division with sieve for x in sieve: if x >= n: return True if n % x == 0: return False # Passed trial division, proceed to Rabin-Miller # Rabin-Miller implemented per Ferguson & Schneier # Compute s, t for Rabin-Miller if display: print("*", end=" ") s, t = n - 1, 0 while s % 2 == 0: s, t = s // 2, t + 1 # Repeat Rabin-Miller x times a = 2 # Use 2 as a base for first iteration speedup, per HAC for count in range(iterations): v = powMod(a, s, n) if v == 1: continue i = 0 while v != n - 1: if i == t - 1: return False else: v, i = powMod(v, 2, n), i + 1 a = getRandomNumber(2, n) return True def getRandomPrime(bits, display=False): if bits < 10: raise AssertionError() # The 1.5 ensures the 2 MSBs are set # Thus, when used for p,q in RSA, n will have its MSB set # # Since 30 is lcm(2,3,5), we'll set our test numbers to # 29 % 30 and keep them there low = ((2 ** (bits - 1)) * 3) // 2 high = 2**bits - 30 p = getRandomNumber(low, high) p += 29 - (p % 30) while 1: if display: print(".", end=" ") p += 30 if p >= high: p = getRandomNumber(low, high) p += 29 - (p % 30) if isPrime(p, display=display): return p # Unused at the moment... def getRandomSafePrime(bits, display=False): if bits < 10: raise AssertionError() # The 1.5 ensures the 2 MSBs are set # Thus, when used for p,q in RSA, n will have its MSB set # # Since 30 is lcm(2,3,5), we'll set our test numbers to # 29 % 30 and keep them there low = (2 ** (bits - 2)) * 3 // 2 high = (2 ** (bits - 1)) - 30 q = getRandomNumber(low, high) q += 29 - (q % 30) while 1: if display: print(".", end=" ") q += 30 if q >= high: q = getRandomNumber(low, high) q += 29 - (q % 30) # Ideas from Tom Wu's SRP code # Do trial division on p and q before Rabin-Miller if isPrime(q, 0, display=display): p = (2 * q) + 1 if isPrime(p, display=display): if isPrime(q, display=display): return p class RSAKey(object): def __init__(self, n=0, e=0, d=0, p=0, q=0, dP=0, dQ=0, qInv=0): if (n and not e) or (e and not n): raise AssertionError() self.n = n self.e = e self.d = d self.p = p self.q = q self.dP = dP self.dQ = dQ self.qInv = qInv self.blinder = 0 self.unblinder = 0 def __len__(self): """Return the length of this key in bits. @rtype: int """ return numBits(self.n) def hasPrivateKey(self): return self.d != 0 - def hashAndSign(self, bytes): - """Hash and sign the passed-in bytes. - - This requires the key to have a private component. It performs - a PKCS1-SHA1 signature on the passed-in data. - - @type bytes: str or L{bytearray} of unsigned bytes - @param bytes: The value which will be hashed and signed. - - @rtype: L{bytearray} of unsigned bytes. - @return: A PKCS1-SHA1 signature on the passed-in data. - """ - hashBytes = SHA1(bytearray(bytes)) - prefixedHashBytes = self._addPKCS1SHA1Prefix(hashBytes) - sigBytes = self.sign(prefixedHashBytes) - return sigBytes - - def hashAndVerify(self, sigBytes, bytes): + def hashAndVerify(self, sigBytes, data): """Hash and verify the passed-in bytes with the signature. This verifies a PKCS1-SHA1 signature on the passed-in data. @type sigBytes: L{bytearray} of unsigned bytes @param sigBytes: A PKCS1-SHA1 signature. - @type bytes: str or L{bytearray} of unsigned bytes - @param bytes: The value which will be hashed and verified. + @type data: str or L{bytearray} of unsigned bytes + @param data: The value which will be hashed and verified. @rtype: bool @return: Whether the signature matches the passed-in data. """ - hashBytes = SHA1(bytearray(bytes)) + hashBytes = SHA1(bytearray(data)) # Try it with/without the embedded NULL prefixedHashBytes1 = self._addPKCS1SHA1Prefix(hashBytes, False) prefixedHashBytes2 = self._addPKCS1SHA1Prefix(hashBytes, True) result1 = self.verify(sigBytes, prefixedHashBytes1) result2 = self.verify(sigBytes, prefixedHashBytes2) return result1 or result2 - def sign(self, bytes): + def sign(self, data): """Sign the passed-in bytes. This requires the key to have a private component. It performs a PKCS1 signature on the passed-in data. - @type bytes: L{bytearray} of unsigned bytes - @param bytes: The value which will be signed. + @type data: L{bytearray} of unsigned bytes + @param data: The value which will be signed. @rtype: L{bytearray} of unsigned bytes. @return: A PKCS1 signature on the passed-in data. """ if not self.hasPrivateKey(): raise AssertionError() - paddedBytes = self._addPKCS1Padding(bytes, 1) + paddedBytes = self._addPKCS1Padding(data, 1) m = bytesToNumber(paddedBytes) if m >= self.n: raise ValueError() c = self._rawPrivateKeyOp(m) sigBytes = numberToByteArray(c, numBytes(self.n)) return sigBytes - def verify(self, sigBytes, bytes): + def verify(self, sigBytes, data): """Verify the passed-in bytes with the signature. This verifies a PKCS1 signature on the passed-in data. @type sigBytes: L{bytearray} of unsigned bytes @param sigBytes: A PKCS1 signature. - @type bytes: L{bytearray} of unsigned bytes - @param bytes: The value which will be verified. + @type data: L{bytearray} of unsigned bytes + @param data: The value which will be verified. @rtype: bool @return: Whether the signature matches the passed-in data. """ if len(sigBytes) != numBytes(self.n): return False - paddedBytes = self._addPKCS1Padding(bytes, 1) + paddedBytes = self._addPKCS1Padding(data, 1) c = bytesToNumber(sigBytes) if c >= self.n: return False m = self._rawPublicKeyOp(c) checkBytes = numberToByteArray(m, numBytes(self.n)) return checkBytes == paddedBytes - def encrypt(self, bytes): + def encrypt(self, data): """Encrypt the passed-in bytes. This performs PKCS1 encryption of the passed-in data. - @type bytes: L{bytearray} of unsigned bytes - @param bytes: The value which will be encrypted. + @type data: L{bytearray} of unsigned bytes + @param data: The value which will be encrypted. @rtype: L{bytearray} of unsigned bytes. @return: A PKCS1 encryption of the passed-in data. """ - paddedBytes = self._addPKCS1Padding(bytes, 2) + paddedBytes = self._addPKCS1Padding(data, 2) m = bytesToNumber(paddedBytes) if m >= self.n: raise ValueError() c = self._rawPublicKeyOp(m) encBytes = numberToByteArray(c, numBytes(self.n)) return encBytes def decrypt(self, encBytes): """Decrypt the passed-in bytes. This requires the key to have a private component. It performs PKCS1 decryption of the passed-in data. @type encBytes: L{bytearray} of unsigned bytes @param encBytes: The value which will be decrypted. @rtype: L{bytearray} of unsigned bytes or None. @return: A PKCS1 decryption of the passed-in data or None if the data is not properly formatted. """ if not self.hasPrivateKey(): raise AssertionError() if len(encBytes) != numBytes(self.n): return None c = bytesToNumber(encBytes) if c >= self.n: return None m = self._rawPrivateKeyOp(c) decBytes = numberToByteArray(m, numBytes(self.n)) # Check first two bytes if decBytes[0] != 0 or decBytes[1] != 2: return None # Scan through for zero separator for x in range(1, len(decBytes) - 1): if decBytes[x] == 0: break else: return None return decBytes[x + 1 :] # Return everything after the separator # ************************************************************************** # Helper Functions for RSA Keys # ************************************************************************** - def _addPKCS1SHA1Prefix(self, bytes, withNULL=True): + def _addPKCS1SHA1Prefix(self, data, withNULL=True): # There is a long history of confusion over whether the SHA1 # algorithmIdentifier should be encoded with a NULL parameter or # with the parameter omitted. While the original intention was # apparently to omit it, many toolkits went the other way. TLS 1.2 # specifies the NULL should be included, and this behavior is also # mandated in recent versions of PKCS #1, and is what tlslite has # always implemented. Anyways, verification code should probably # accept both. However, nothing uses this code yet, so this is # all fairly moot. if not withNULL: prefixBytes = bytearray( [ 0x30, 0x1F, 0x30, 0x07, 0x06, 0x05, 0x2B, 0x0E, 0x03, 0x02, 0x1A, 0x04, 0x14, ] ) else: prefixBytes = bytearray( [ 0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2B, 0x0E, 0x03, 0x02, 0x1A, 0x05, 0x00, 0x04, 0x14, ] ) - prefixedBytes = prefixBytes + bytes - return prefixedBytes + return prefixBytes + data - def _addPKCS1Padding(self, bytes, blockType): - padLength = numBytes(self.n) - (len(bytes) + 3) + def _addPKCS1Padding(self, data, blockType): + padLength = numBytes(self.n) - (len(data) + 3) if blockType == 1: # Signature padding pad = [0xFF] * padLength elif blockType == 2: # Encryption padding pad = bytearray(0) while len(pad) < padLength: padBytes = getRandomBytes(padLength * 2) pad = [b for b in padBytes if b != 0] pad = pad[:padLength] else: raise AssertionError() padding = bytearray([0, blockType] + pad + [0]) - paddedBytes = padding + bytes - return paddedBytes + return padding + data def _rawPrivateKeyOp(self, m): # Create blinding values, on the first pass: if not self.blinder: self.unblinder = getRandomNumber(2, self.n) self.blinder = powMod(invMod(self.unblinder, self.n), self.e, self.n) # Blind the input m = (m * self.blinder) % self.n # Perform the RSA operation c = self._rawPrivateKeyOpHelper(m) # Unblind the output c = (c * self.unblinder) % self.n # Update blinding values self.blinder = (self.blinder * self.blinder) % self.n self.unblinder = (self.unblinder * self.unblinder) % self.n # Return the output return c def _rawPrivateKeyOpHelper(self, m): # Non-CRT version # c = powMod(m, self.d, self.n) # CRT version (~3x faster) s1 = powMod(m, self.dP, self.p) s2 = powMod(m, self.dQ, self.q) h = ((s1 - s2) * self.qInv) % self.p c = s2 + self.q * h return c def _rawPublicKeyOp(self, c): m = powMod(c, self.e, self.n) return m def acceptsPassword(self): return False def generate(bits): key = RSAKey() p = getRandomPrime(bits // 2, False) q = getRandomPrime(bits // 2, False) t = lcm(p - 1, q - 1) key.n = p * q key.e = 65537 key.d = invMod(key.e, t) key.p = p key.q = q key.dP = key.d % (p - 1) key.dQ = key.d % (q - 1) key.qInv = invMod(q, p) return key generate = staticmethod(generate) diff --git a/electrum/electrumabc/transaction.py b/electrum/electrumabc/transaction.py index d82803ca7..376588308 100644 --- a/electrum/electrumabc/transaction.py +++ b/electrum/electrumabc/transaction.py @@ -1,1693 +1,1680 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2011 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. import hashlib import random import struct import warnings from typing import List, NamedTuple, Optional, Tuple, Union import ecdsa from . import bitcoin, schnorr from .address import ( Address, DestinationType, P2PKH_prefix, P2PKH_suffix, P2SH_prefix, P2SH_suffix, PublicKey, Script, ScriptOutput, UnknownAddress, ) from .bitcoin import TYPE_SCRIPT from .bitcoin import OpCodes as opcodes from .caches import ExpiringCache from .constants import DEFAULT_TXIN_SEQUENCE # # Workalike python implementation of Bitcoin's CDataStream class. # from .keystore import xpubkey_to_address, xpubkey_to_pubkey from .printerror import print_error from .util import bfh, bh2u, profiler, to_bytes # Note: The deserialization code originally comes from ABE. NO_SIGNATURE = "ff" class SerializationError(Exception): """Thrown when there's a problem deserializing or serializing""" class InputValueMissing(ValueError): """thrown when the value of an input is needed but not present""" class TxOutput(NamedTuple): type: int destination: DestinationType # str when the output is set to max: '!' value: Union[int, str] def is_opreturn(self) -> bool: if not self.type == TYPE_SCRIPT or not isinstance( self.destination, ScriptOutput ): return False ops = Script.get_ops(self.destination.script) return len(ops) >= 1 and ops[0][0] == opcodes.OP_RETURN class BCDataStream(object): def __init__(self): self.input = None self.read_cursor = 0 def clear(self): self.input = None self.read_cursor = 0 def write(self, _bytes): # Initialize with string of _bytes if self.input is None: self.input = bytearray(_bytes) else: self.input += bytearray(_bytes) def read_string(self, encoding="ascii"): # Strings are encoded depending on length: # 0 to 252 : 1-byte-length followed by bytes (if any) # 253 to 65,535 : byte'253' 2-byte-length followed by bytes # 65,536 to 4,294,967,295 : byte '254' 4-byte-length followed by bytes # ... and the Bitcoin client is coded to understand: # greater than 4,294,967,295 : byte '255' 8-byte-length followed by bytes of string # ... but I don't think it actually handles any strings that big. if self.input is None: raise SerializationError("call write(bytes) before trying to deserialize") length = self.read_compact_size() return self.read_bytes(length).decode(encoding) def write_string(self, string, encoding="ascii"): string = to_bytes(string, encoding) # Length-encoded as with read-string self.write_compact_size(len(string)) self.write(string) def read_bytes(self, length): try: result = self.input[self.read_cursor : self.read_cursor + length] self.read_cursor += length return result except IndexError: raise SerializationError("attempt to read past end of buffer") return "" def can_read_more(self) -> bool: if not self.input: return False return self.read_cursor < len(self.input) def read_boolean(self): return self.read_bytes(1)[0] != chr(0) def read_int16(self): return self._read_num(" 0 ): # Opcodes below OP_PUSHDATA4 just push data onto stack, and are equivalent. # Note we explicitly don't match OP_0, OP_1 through OP_16 and OP1_NEGATE here continue if to_match[i] != op: return False return True def parse_sig(x_sig): return [None if x == NO_SIGNATURE else x for x in x_sig] def safe_parse_pubkey(x): try: return xpubkey_to_pubkey(x) except Exception: return x def parse_scriptSig(d, _bytes): try: decoded = Script.get_ops(_bytes) except Exception: # coinbase transactions raise an exception print_error("cannot find address in input script", bh2u(_bytes)) return match = [opcodes.OP_PUSHDATA4] if match_decoded(decoded, match): item = decoded[0][1] # payto_pubkey d["type"] = "p2pk" d["signatures"] = [bh2u(item)] d["num_sig"] = 1 d["x_pubkeys"] = ["(pubkey)"] d["pubkeys"] = ["(pubkey)"] return # non-generated TxIn transactions push a signature # (seventy-something bytes) and then their public key # (65 bytes) onto the stack: match = [opcodes.OP_PUSHDATA4, opcodes.OP_PUSHDATA4] if match_decoded(decoded, match): sig = bh2u(decoded[0][1]) x_pubkey = bh2u(decoded[1][1]) try: signatures = parse_sig([sig]) pubkey, address = xpubkey_to_address(x_pubkey) except Exception: print_error("cannot find address in input script", bh2u(_bytes)) return d["type"] = "p2pkh" d["signatures"] = signatures d["x_pubkeys"] = [x_pubkey] d["num_sig"] = 1 d["pubkeys"] = [pubkey] d["address"] = address return # p2sh transaction, m of n match = [opcodes.OP_0] + [opcodes.OP_PUSHDATA4] * (len(decoded) - 1) if not match_decoded(decoded, match): print_error("cannot find address in input script", bh2u(_bytes)) return x_sig = [bh2u(x[1]) for x in decoded[1:-1]] m, n, x_pubkeys, pubkeys, redeemScript = parse_redeemScript(decoded[-1][1]) # write result in d d["type"] = "p2sh" d["num_sig"] = m d["signatures"] = parse_sig(x_sig) d["x_pubkeys"] = x_pubkeys d["pubkeys"] = pubkeys d["redeemScript"] = redeemScript d["address"] = Address.from_P2SH_hash(bitcoin.hash_160(redeemScript)) def parse_redeemScript(s): dec2 = Script.get_ops(s) # the following throw exception when redeemscript has one or zero opcodes m = dec2[0][0] - opcodes.OP_1 + 1 n = dec2[-2][0] - opcodes.OP_1 + 1 op_m = opcodes.OP_1 + m - 1 op_n = opcodes.OP_1 + n - 1 match_multisig = ( [op_m] + [opcodes.OP_PUSHDATA4] * n + [op_n, opcodes.OP_CHECKMULTISIG] ) if not match_decoded(dec2, match_multisig): # causes exception in caller when mismatched print_error("cannot find address in input script", bh2u(s)) return x_pubkeys = [bh2u(x[1]) for x in dec2[1:-2]] pubkeys = [safe_parse_pubkey(x) for x in x_pubkeys] redeemScript = Script.multisig_script(m, [bytes.fromhex(p) for p in pubkeys]) return m, n, x_pubkeys, pubkeys, redeemScript def get_address_from_output_script( _bytes: bytes, ) -> Tuple[int, Union[PublicKey, DestinationType]]: """Return the type of the output and the address""" scriptlen = len(_bytes) if ( scriptlen == 23 and _bytes.startswith(P2SH_prefix) and _bytes.endswith(P2SH_suffix) ): # Pay-to-script-hash return bitcoin.TYPE_ADDRESS, Address.from_P2SH_hash(_bytes[2:22]) if ( scriptlen == 25 and _bytes.startswith(P2PKH_prefix) and _bytes.endswith(P2PKH_suffix) ): # Pay-to-pubkey-hash return bitcoin.TYPE_ADDRESS, Address.from_P2PKH_hash(_bytes[3:23]) if ( scriptlen == 35 and _bytes[0] == 33 and _bytes[1] in (2, 3) and _bytes[34] == opcodes.OP_CHECKSIG ): # Pay-to-pubkey (compressed) return bitcoin.TYPE_PUBKEY, PublicKey.from_pubkey(_bytes[1:34]) if ( scriptlen == 67 and _bytes[0] == 65 and _bytes[1] == 4 and _bytes[66] == opcodes.OP_CHECKSIG ): # Pay-to-pubkey (uncompressed) return bitcoin.TYPE_PUBKEY, PublicKey.from_pubkey(_bytes[1:66]) # note: we don't recognize bare multisigs. return bitcoin.TYPE_SCRIPT, ScriptOutput.protocol_factory(bytes(_bytes)) def parse_input(vds): d = {} prevout_hash = bitcoin.hash_encode(vds.read_bytes(32)) prevout_n = vds.read_uint32() scriptSig = vds.read_bytes(vds.read_compact_size()) sequence = vds.read_uint32() d["prevout_hash"] = prevout_hash d["prevout_n"] = prevout_n d["sequence"] = sequence d["address"] = UnknownAddress() if prevout_hash == "00" * 32: d["type"] = "coinbase" d["scriptSig"] = bh2u(scriptSig) else: d["x_pubkeys"] = [] d["pubkeys"] = [] d["signatures"] = {} d["address"] = None d["type"] = "unknown" d["num_sig"] = 0 d["scriptSig"] = bh2u(scriptSig) try: parse_scriptSig(d, scriptSig) except Exception as e: print_error( "{}: Failed to parse tx input {}:{}, probably a p2sh (non multisig?)." " Exception was: {}".format(__name__, prevout_hash, prevout_n, repr(e)) ) # that whole heuristic codepath is fragile; just ignore it when it dies. # failing tx examples: # 1c671eb25a20aaff28b2fa4254003c201155b54c73ac7cf9c309d835deed85ee # 08e1026eaf044127d7103415570afd564dfac3131d7a5e4b645f591cd349bb2c # override these once more just to make sure d["address"] = UnknownAddress() d["type"] = "unknown" if not Transaction.is_txin_complete(d): del d["scriptSig"] d["value"] = vds.read_uint64() return d def parse_output(vds: BCDataStream, i: int): d = {} d["value"] = vds.read_int64() scriptPubKey = vds.read_bytes(vds.read_compact_size()) d["type"], d["address"] = get_address_from_output_script(scriptPubKey) d["scriptPubKey"] = bh2u(scriptPubKey) d["prevout_n"] = i return d def deserialize(raw): vds = BCDataStream() vds.write(bfh(raw)) d = {} d["version"] = vds.read_int32() n_vin = vds.read_compact_size() d["inputs"] = [parse_input(vds) for i in range(n_vin)] n_vout = vds.read_compact_size() d["outputs"] = [parse_output(vds, i) for i in range(n_vout)] d["lockTime"] = vds.read_uint32() if vds.can_read_more(): raise SerializationError("extra junk at the end") return d # pay & redeem scripts def multisig_script(public_keys, m): n = len(public_keys) assert n <= 15 assert m <= n op_m = bitcoin.push_script_bytes(bytes([m])).hex() op_n = bitcoin.push_script_bytes(bytes([n])).hex() keylist = [bitcoin.push_script(k) for k in public_keys] return op_m + "".join(keylist) + op_n + bytes([opcodes.OP_CHECKMULTISIG]).hex() class Transaction: SIGHASH_FORKID = 0x40 # do not use this; deprecated FORKID = 0x000000 # do not use this; deprecated def __str__(self): if self.raw is None: self.raw = self.serialize() return self.raw def __init__(self, raw, sign_schnorr=False): if raw is None: self.raw = None elif isinstance(raw, str): self.raw = raw.strip() if raw else None elif isinstance(raw, dict): self.raw = raw["hex"] else: raise RuntimeError("cannot initialize transaction", raw) self._inputs = None self._outputs: Optional[List[TxOutput]] = None self.locktime = 0 self.version = 2 self._sign_schnorr = sign_schnorr # attribute used by HW wallets to tell the hw keystore about any outputs # in the tx that are to self (change), etc. See wallet.py add_hw_info # which writes to this dict and the various hw wallet plugins which # read this dict. self.output_info = dict() # Ephemeral meta-data used internally to keep track of interesting # things. This is currently written-to by coinchooser to tell UI code # about 'dust_to_fee', which is change that's too small to go to change # outputs (below dust threshold) and needed to go to the fee. # # It is also used to store the 'fetched_inputs' which are asynchronously # retrieved inputs (by retrieving prevout_hash tx's), see # `fetch_input_data`. # # Values in this dict are advisory only and may or may not always be # there! self.ephemeral = dict() def is_memory_compact(self): """Returns True if the tx is stored in memory only as self.raw (serialized) and has no deserialized data structures currently in memory.""" return ( self.raw is not None and self._inputs is None and self._outputs is None and self.locktime == 0 and self.version == 1 ) def set_sign_schnorr(self, b): self._sign_schnorr = b def update(self, raw): self.raw = raw self._inputs = None self.deserialize() def inputs(self): if self._inputs is None: self.deserialize() return self._inputs def outputs(self) -> List[TxOutput]: if self._outputs is None: self.deserialize() return self._outputs @classmethod def get_sorted_pubkeys(self, txin): # sort pubkeys and x_pubkeys, using the order of pubkeys # Note: this function is CRITICAL to get the correct order of pubkeys in # multisignatures; avoid changing. x_pubkeys = txin["x_pubkeys"] pubkeys = txin.get("pubkeys") if pubkeys is None: pubkeys = [xpubkey_to_pubkey(x) for x in x_pubkeys] pubkeys, x_pubkeys = zip(*sorted(zip(pubkeys, x_pubkeys))) txin["pubkeys"] = pubkeys = list(pubkeys) txin["x_pubkeys"] = x_pubkeys = list(x_pubkeys) return pubkeys, x_pubkeys def update_signatures(self, signatures): """Add new signatures to a transaction `signatures` is expected to be a list of hex encoded sig strings with *no* sighash byte at the end (implicitly always 0x41 (SIGHASH_FORKID|SIGHASH_ALL); will be added by this function). signatures[i] is intended for self._inputs[i]. The signature will be matched with the appropriate pubkey automatically in the case of multisignature wallets. This function is used by the Trezor, KeepKey, etc to update the transaction with signatures form the device. Note this function supports both Schnorr and ECDSA signatures, but as yet no hardware wallets are signing Schnorr. """ if self.is_complete(): return if not isinstance(signatures, (tuple, list)): raise Exception("API changed: update_signatures expects a list.") if len(self.inputs()) != len(signatures): raise Exception( "expected {} signatures; got {}".format( len(self.inputs()), len(signatures) ) ) for i, txin in enumerate(self.inputs()): pubkeys, x_pubkeys = self.get_sorted_pubkeys(txin) sig = signatures[i] if not isinstance(sig, str): raise ValueError("sig was bytes, expected string") # sig_final is the signature with the sighashbyte at the end (0x41) sig_final = sig + "41" if sig_final in txin.get("signatures"): # skip if we already have this signature continue pre_hash = bitcoin.Hash(bfh(self.serialize_preimage(i))) sig_bytes = bfh(sig) added = False reason = [] for j, pubkey in enumerate(pubkeys): # see which pubkey matches this sig (in non-multisig only 1 pubkey, in multisig may be multiple pubkeys) if self.verify_signature(bfh(pubkey), sig_bytes, pre_hash, reason): print_error("adding sig", i, j, pubkey, sig_final) self._inputs[i]["signatures"][j] = sig_final added = True if not added: resn = ", ".join(reversed(reason)) if reason else "" print_error( "failed to add signature {} for any pubkey for reason(s): '{}' ;" " pubkey(s) / sig / pre_hash = ".format(i, resn), pubkeys, "/", sig, "/", bh2u(pre_hash), ) # redo raw self.raw = self.serialize() def is_schnorr_signed(self, input_idx): """Return True IFF any of the signatures for a particular input are Schnorr signatures (Schnorr signatures are always 64 bytes + 1)""" if ( isinstance(self._inputs, (list, tuple)) and input_idx < len(self._inputs) and self._inputs[input_idx] ): # Schnorr sigs are always 64 bytes. However the sig has a hash byte # at the end, so that's 65. Plus we are hex encoded, so 65*2=130 return any( isinstance(sig, (str, bytes)) and len(sig) == 130 for sig in self._inputs[input_idx].get("signatures", []) ) return False def deserialize(self): if self.raw is None: return if self._inputs is not None: return d = deserialize(self.raw) self.invalidate_common_sighash_cache() self._inputs = d["inputs"] self._outputs = [ TxOutput(x["type"], x["address"], x["value"]) for x in d["outputs"] ] assert all( isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in self._outputs ) self.locktime = d["lockTime"] self.version = d["version"] return d @classmethod def from_io( klass, inputs, outputs: List[TxOutput], locktime=0, sign_schnorr=False, version=None, ): assert all( isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in outputs ) self = klass(None) self._inputs = inputs self._outputs = outputs.copy() self.locktime = locktime if version is not None: self.version = version self.set_sign_schnorr(sign_schnorr) return self @classmethod def pay_script(self, output): return output.to_script().hex() @classmethod def estimate_pubkey_size_from_x_pubkey(cls, x_pubkey): try: if x_pubkey[0:2] in ["02", "03"]: # compressed pubkey return 0x21 elif x_pubkey[0:2] == "04": # uncompressed pubkey return 0x41 elif x_pubkey[0:2] == "ff": # bip32 extended pubkey return 0x21 elif x_pubkey[0:2] == "fe": # old electrum extended pubkey return 0x41 except Exception: pass return 0x21 # just guess it is compressed @classmethod def estimate_pubkey_size_for_txin(cls, txin): pubkeys = txin.get("pubkeys", []) x_pubkeys = txin.get("x_pubkeys", []) if pubkeys and len(pubkeys) > 0: return cls.estimate_pubkey_size_from_x_pubkey(pubkeys[0]) elif x_pubkeys and len(x_pubkeys) > 0: return cls.estimate_pubkey_size_from_x_pubkey(x_pubkeys[0]) else: return 0x21 # just guess it is compressed @classmethod def get_siglist(self, txin, estimate_size=False, sign_schnorr=False): # if we have enough signatures, we use the actual pubkeys # otherwise, use extended pubkeys (with bip32 derivation) num_sig = txin.get("num_sig", 1) if estimate_size: pubkey_size = self.estimate_pubkey_size_for_txin(txin) pk_list = ["00" * pubkey_size] * len(txin.get("x_pubkeys", [None])) # we assume that signature will be 0x48 bytes long if ECDSA, 0x41 if Schnorr if sign_schnorr: siglen = 0x41 else: siglen = 0x48 sig_list = ["00" * siglen] * num_sig else: pubkeys, x_pubkeys = self.get_sorted_pubkeys(txin) x_signatures = txin["signatures"] signatures = list(filter(None, x_signatures)) is_complete = len(signatures) == num_sig if is_complete: pk_list = pubkeys sig_list = signatures else: pk_list = x_pubkeys sig_list = [sig if sig else NO_SIGNATURE for sig in x_signatures] return pk_list, sig_list @classmethod def input_script(self, txin, estimate_size=False, sign_schnorr=False): # For already-complete transactions, scriptSig will be set and we prefer # to use it verbatim in order to get an exact reproduction (including # malleated push opcodes, etc.). scriptSig = txin.get("scriptSig", None) if scriptSig is not None: return scriptSig # For partially-signed inputs, or freshly signed transactions, the # scriptSig will be missing and so we construct it from pieces. _type = txin["type"] if _type == "coinbase": raise RuntimeError("Attempted to serialize coinbase with missing scriptSig") pubkeys, sig_list = self.get_siglist( txin, estimate_size, sign_schnorr=sign_schnorr ) script = "".join(bitcoin.push_script(x) for x in sig_list) if _type == "p2pk": pass elif _type == "p2sh": # put op_0 before script script = "00" + script redeem_script = multisig_script(pubkeys, txin["num_sig"]) script += bitcoin.push_script(redeem_script) elif _type == "p2pkh": script += bitcoin.push_script(pubkeys[0]) elif _type == "unknown": raise RuntimeError("Cannot serialize unknown input with missing scriptSig") return script @classmethod def is_txin_complete(cls, txin): if txin["type"] == "coinbase": return True num_sig = txin.get("num_sig", 1) if num_sig == 0: return True x_signatures = txin["signatures"] signatures = list(filter(None, x_signatures)) return len(signatures) == num_sig @classmethod def get_preimage_script(self, txin): _type = txin["type"] if _type == "p2pkh": return txin["address"].to_script().hex() elif _type == "p2sh": pubkeys, x_pubkeys = self.get_sorted_pubkeys(txin) return multisig_script(pubkeys, txin["num_sig"]) elif _type == "p2pk": pubkey = txin["pubkeys"][0] return bitcoin.public_key_to_p2pk_script(pubkey) elif _type == "unknown": # this approach enables most P2SH smart contracts (but take care if using OP_CODESEPARATOR) return txin["scriptCode"] else: raise RuntimeError("Unknown txin type", _type) @classmethod def serialize_outpoint(self, txin): return bh2u(bfh(txin["prevout_hash"])[::-1]) + bitcoin.int_to_hex( txin["prevout_n"], 4 ) @classmethod def serialize_input(self, txin, script, estimate_size=False): # Prev hash and index s = self.serialize_outpoint(txin) # Script length, script, sequence s += bitcoin.var_int(len(script) // 2) s += script s += bitcoin.int_to_hex(txin.get("sequence", DEFAULT_TXIN_SEQUENCE), 4) # offline signing needs to know the input value if ( "value" in txin and txin.get("scriptSig") is None and not (estimate_size or self.is_txin_complete(txin)) ): s += bitcoin.int_to_hex(txin["value"], 8) return s def shuffle_inputs(self): random.shuffle(self._inputs) def sort_outputs(self, shuffle: bool = True): """Put the op_return output first, and then shuffle the other outputs unless this behavior is explicitly disabled.""" op_returns = [] other_outputs = [] for txo in self._outputs: if txo.is_opreturn(): op_returns.append(txo) else: other_outputs.append(txo) if shuffle: random.shuffle(other_outputs) self._outputs = op_returns + other_outputs def serialize_output(self, output): output_type, addr, amount = output s = bitcoin.int_to_hex(amount, 8) script = self.pay_script(addr) s += bitcoin.var_int(len(script) // 2) s += script return s @classmethod def nHashType(cls): """Hash type in hex.""" warnings.warn("warning: deprecated tx.nHashType()", FutureWarning, stacklevel=2) return 0x01 | (cls.SIGHASH_FORKID + (cls.FORKID << 8)) def invalidate_common_sighash_cache(self): """Call this to invalidate the cached common sighash (computed by `calc_common_sighash` below). This is function is for advanced usage of this class where the caller has mutated the transaction after computing its signatures and would like to explicitly delete the cached common sighash. See `calc_common_sighash` below.""" try: del self._cached_sighash_tup except AttributeError: pass def calc_common_sighash(self, use_cache=False): """Calculate the common sighash components that are used by transaction signatures. If `use_cache` enabled then this will return already-computed values from the `._cached_sighash_tup` attribute, or compute them if necessary (and then store). 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. Returns three 32-long bytes objects: (hashPrevouts, hashSequence, hashOutputs). Warning: If you modify non-signature parts of the transaction afterwards, this cache will be wrong!""" inputs = self.inputs() outputs = self.outputs() meta = (len(inputs), len(outputs)) if use_cache: try: cmeta, res = self._cached_sighash_tup except AttributeError: pass else: # minimal heuristic check to detect bad cached value if cmeta == meta: # cache hit and heuristic check ok return res else: del cmeta, res, self._cached_sighash_tup hashPrevouts = bitcoin.Hash( bfh("".join(self.serialize_outpoint(txin) for txin in inputs)) ) hashSequence = bitcoin.Hash( bfh( "".join( bitcoin.int_to_hex(txin.get("sequence", DEFAULT_TXIN_SEQUENCE), 4) for txin in inputs ) ) ) hashOutputs = bitcoin.Hash( bfh("".join(self.serialize_output(o) for o in outputs)) ) res = hashPrevouts, hashSequence, hashOutputs # cach resulting value, along with some minimal metadata to defensively # program against cache invalidation (due to class mutation). self._cached_sighash_tup = meta, res return res def serialize_preimage(self, i, nHashType=0x00000041, use_cache=False): """See `.calc_common_sighash` for explanation of use_cache feature""" if (nHashType & 0xFF) != 0x41: raise ValueError("other hashtypes not supported; submit a PR to fix this!") nVersion = bitcoin.int_to_hex(self.version, 4) nHashType = bitcoin.int_to_hex(nHashType, 4) nLocktime = bitcoin.int_to_hex(self.locktime, 4) txin = self.inputs()[i] outpoint = self.serialize_outpoint(txin) preimage_script = self.get_preimage_script(txin) scriptCode = bitcoin.var_int(len(preimage_script) // 2) + preimage_script try: amount = bitcoin.int_to_hex(txin["value"], 8) except KeyError: raise InputValueMissing nSequence = bitcoin.int_to_hex(txin.get("sequence", DEFAULT_TXIN_SEQUENCE), 4) hashPrevouts, hashSequence, hashOutputs = self.calc_common_sighash( use_cache=use_cache ) preimage = ( nVersion + bh2u(hashPrevouts) + bh2u(hashSequence) + outpoint + scriptCode + amount + nSequence + bh2u(hashOutputs) + nLocktime + nHashType ) return preimage def serialize(self, estimate_size=False): nVersion = bitcoin.int_to_hex(self.version, 4) nLocktime = bitcoin.int_to_hex(self.locktime, 4) inputs = self.inputs() outputs = self.outputs() txins = bitcoin.var_int(len(inputs)) + "".join( self.serialize_input( txin, self.input_script(txin, estimate_size, self._sign_schnorr), estimate_size, ) for txin in inputs ) txouts = bitcoin.var_int(len(outputs)) + "".join( self.serialize_output(o) for o in outputs ) return nVersion + txins + txouts + nLocktime def hash(self): warnings.warn("warning: deprecated tx.hash()", FutureWarning, stacklevel=2) return self.txid() def txid(self): if not self.is_complete(): return None ser = self.serialize() return self._txid(ser) def txid_fast(self): """Returns the txid by immediately calculating it from self.raw, which is faster than calling txid() which does a full re-serialize each time. Note this should only be used for tx's that you KNOW are complete and that don't contain our funny serialization hacks. (The is_complete check is also not performed here because that potentially can lead to unwanted tx deserialization).""" if self.raw: return self._txid(self.raw) return self.txid() @staticmethod def _txid(raw_hex: str) -> str: return bh2u(bitcoin.Hash(bfh(raw_hex))[::-1]) def add_inputs(self, inputs): self._inputs.extend(inputs) self.raw = None def set_inputs(self, inputs): self._inputs = inputs self.raw = None def add_outputs(self, outputs): assert all( isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in outputs ) self._outputs.extend(outputs) self.raw = None def set_outputs(self, outputs): assert all( isinstance(output[1], (PublicKey, Address, ScriptOutput)) for output in outputs ) self._outputs = outputs self.raw = None def input_value(self): """Will return the sum of all input values, if the input values are known (may consult self.fetched_inputs() to get a better idea of possible input values). Will raise InputValueMissing if input values are missing.""" try: return sum(x["value"] for x in (self.fetched_inputs() or self.inputs())) except (KeyError, TypeError, ValueError) as e: raise InputValueMissing from e def output_value(self): return sum(val for tp, addr, val in self.outputs()) def get_fee(self): """Try and calculate the fee based on the input data, and returns it as satoshis (int). Can raise InputValueMissing on tx's where fee data is missing, so client code should catch that.""" # first, check if coinbase; coinbase tx always has 0 fee if self.inputs() and self._inputs[0].get("type") == "coinbase": return 0 # otherwise just sum up all values - may raise InputValueMissing return self.input_value() - self.output_value() @profiler def estimated_size(self): """Return an estimated tx size in bytes.""" return ( len(self.serialize(True)) // 2 if not self.is_complete() or self.raw is None else len(self.raw) // 2 ) # ASCII hex string @classmethod def estimated_input_size(self, txin, sign_schnorr=False): """Return an estimated of serialized input size in bytes.""" script = self.input_script(txin, True, sign_schnorr=sign_schnorr) return len(self.serialize_input(txin, script, True)) // 2 # ASCII hex string def signature_count(self): r = 0 s = 0 for txin in self.inputs(): if txin["type"] == "coinbase": continue signatures = list(filter(None, txin.get("signatures", []))) s += len(signatures) r += txin.get("num_sig", -1) return s, r def is_complete(self): s, r = self.signature_count() return r == s @staticmethod def verify_signature(pubkey, sig, msghash, reason=None): """Given a pubkey (bytes), signature (bytes -- without sighash byte), and a sha256d message digest, returns True iff the signature is good for the given public key, False otherwise. Does not raise normally unless given bad or garbage arguments. Optional arg 'reason' should be a list which will have a string pushed at the front (failure reason) on False return.""" if ( any(not arg or not isinstance(arg, bytes) for arg in (pubkey, sig, msghash)) or len(msghash) != 32 ): raise ValueError("bad arguments to verify_signature") if len(sig) == 64: # Schnorr signatures are always exactly 64 bytes return schnorr.verify(pubkey, sig, msghash) else: from ecdsa import BadDigestError, BadSignatureError from ecdsa.der import UnexpectedDER # ECDSA signature try: pubkey_point = bitcoin.ser_to_point(pubkey) vk = bitcoin.MyVerifyingKey.from_public_point( pubkey_point, curve=ecdsa.curves.SECP256k1 ) if vk.verify_digest(sig, msghash, sigdecode=ecdsa.util.sigdecode_der): return True except ( AssertionError, ValueError, TypeError, BadSignatureError, BadDigestError, UnexpectedDER, ) as e: # ser_to_point will fail if pubkey is off-curve, infinity, or garbage. # verify_digest may also raise BadDigestError and BadSignatureError if isinstance(reason, list): reason.insert(0, repr(e)) except Exception as e: print_error( "[Transaction.verify_signature] unexpected exception", repr(e) ) if isinstance(reason, list): reason.insert(0, repr(e)) return False @staticmethod def _ecdsa_sign(sec, pre_hash): pkey = bitcoin.regenerate_key(sec) secexp = pkey.secret private_key = bitcoin.MySigningKey.from_secret_exponent( secexp, curve=ecdsa.curves.SECP256k1 ) public_key = private_key.get_verifying_key() sig = private_key.sign_digest_deterministic( pre_hash, hashfunc=hashlib.sha256, sigencode=ecdsa.util.sigencode_der ) assert public_key.verify_digest( sig, pre_hash, sigdecode=ecdsa.util.sigdecode_der ) return sig @staticmethod def _schnorr_sign(pubkey, sec, pre_hash): pubkey = bytes.fromhex(pubkey) sig = schnorr.sign(sec, pre_hash) assert schnorr.verify(pubkey, sig, pre_hash) # verify what we just signed return sig def sign(self, keypairs, *, use_cache=False): for i, txin in enumerate(self.inputs()): pubkeys, x_pubkeys = self.get_sorted_pubkeys(txin) for j, (pubkey, x_pubkey) in enumerate(zip(pubkeys, x_pubkeys)): if self.is_txin_complete(txin): # txin is complete break if pubkey in keypairs: _pubkey = pubkey kname = "pubkey" elif x_pubkey in keypairs: _pubkey = x_pubkey kname = "x_pubkey" else: continue print_error( f"adding signature for input#{i} sig#{j}; {kname}:" f" {_pubkey} schnorr: {self._sign_schnorr}" ) sec, compressed = keypairs.get(_pubkey) self._sign_txin(i, j, sec, compressed, use_cache=use_cache) print_error("is_complete", self.is_complete()) self.raw = self.serialize() def _sign_txin(self, i, j, sec, compressed, *, use_cache=False): """Note: precondition is self._inputs is valid (ie: tx is already deserialized)""" pubkey = bitcoin.public_key_from_private_key(sec, compressed) # add signature nHashType = ( 0x00000041 # hardcoded, perhaps should be taken from unsigned input dict ) pre_hash = bitcoin.Hash( bfh(self.serialize_preimage(i, nHashType, use_cache=use_cache)) ) if self._sign_schnorr: sig = self._schnorr_sign(pubkey, sec, pre_hash) else: sig = self._ecdsa_sign(sec, pre_hash) reason = [] if not self.verify_signature(bfh(pubkey), sig, pre_hash, reason=reason): print_error( f"Signature verification failed for input#{i} sig#{j}, reason:" f" {str(reason)}" ) return None txin = self._inputs[i] txin["signatures"][j] = bh2u(sig + bytes((nHashType & 0xFF,))) txin["pubkeys"][j] = pubkey # needed for fd keys return txin def is_final(self): return not any( [ x.get("sequence", DEFAULT_TXIN_SEQUENCE) < DEFAULT_TXIN_SEQUENCE for x in self.inputs() ] ) def as_dict(self): if self.raw is None: self.raw = self.serialize() self.deserialize() out = { "hex": self.raw, "complete": self.is_complete(), "final": self.is_final(), } return out # This cache stores foreign (non-wallet) tx's we fetched from the network # for the purposes of the "fetch_input_data" mechanism. Its max size has # been thoughtfully calibrated to provide a decent tradeoff between # memory consumption and UX. # # In even aggressive/pathological cases this cache won't ever exceed # 100MB even when full. [see ExpiringCache.size_bytes() to test it]. # This is acceptable considering this is Python + Qt and it eats memory # anyway.. and also this is 2019 ;). Note that all tx's in this cache # are in the non-deserialized state (hex encoded bytes only) as a memory # savings optimization. Please maintain that invariant if you modify this # code, otherwise the cache may grow to 10x memory consumption if you # put deserialized tx's in here. _fetched_tx_cache = ExpiringCache(maxlen=1000, name="TransactionFetchCache") def fetch_input_data( self, wallet, done_callback=None, done_args=tuple(), prog_callback=None, *, force=False, use_network=True, ): """ Fetch all input data and put it in the 'ephemeral' dictionary, under 'fetched_inputs'. This call potentially initiates fetching of prevout_hash transactions from the network for all inputs to this tx. The fetched data is basically used for the Transaction dialog to be able to display fee, actual address, and amount (value) for tx inputs. `wallet` should ideally have a network object, but this function still will work and is still useful if it does not. `done_callback` is called with `done_args` (only if True was returned), upon completion. Note that done_callback won't be called if this function returns False. Also note that done_callback runs in a non-main thread context and as such, if you want to do GUI work from within it, use the appropriate Qt signal/slot mechanism to dispatch work to the GUI. `prog_callback`, if specified, is called periodically to indicate progress after inputs are retrieved, and it is passed a single arg, "percent" (eg: 5.1, 10.3, 26.3, 76.1, etc) to indicate percent progress. Note 1: Results (fetched transactions) are cached, so subsequent calls to this function for the same transaction are cheap. Note 2: Multiple, rapid calls to this function will cause the previous asynchronous fetch operation (if active) to be canceled and only the latest call will result in the invocation of the done_callback if/when it completes. """ if not self._inputs: return False if force: # forced-run -- start with empty list inps = [] else: # may be a new list or list that was already in dict inps = self.fetched_inputs(require_complete=True) if len(self._inputs) == len(inps): # we already have results, don't do anything. return False eph = self.ephemeral eph["fetched_inputs"] = ( inps ) = inps.copy() # paranoia: in case another thread is running on this list # Lazy imports to keep this functionality very self-contained # These modules are always available so no need to globally import them. import queue import threading import time from collections import defaultdict from copy import deepcopy t0 = time.time() t = None cls = __class__ self_txid = self.txid() def doIt(): """ This function is seemingly complex, but it's really conceptually simple: 1. Fetch all prevouts either from cache (wallet or global tx_cache) 2. Or, if they aren't in either cache, then we will asynchronously queue the raw tx gets to the network in parallel, across *all* our connected servers. This is very fast, and spreads the load around. Tested with a huge tx of 600+ inputs all coming from different prevout_hashes on mainnet, and it's super fast: cd8fcc8ad75267ff9ad314e770a66a9e871be7882b7c05a7e5271c46bfca98bc""" last_prog = -9999.0 need_dl_txids = defaultdict( list ) # the dict of txids we will need to download (wasn't in cache) def prog(i, prog_total=100): """notify interested code about progress""" nonlocal last_prog if prog_callback: prog = ((i + 1) * 100.0) / prog_total if prog - last_prog > 5.0: prog_callback(prog) last_prog = prog while eph.get("_fetch") == t and len(inps) < len(self._inputs): i = len(inps) inp = deepcopy(self._inputs[i]) typ, prevout_hash, n, addr, value = ( inp.get("type"), inp.get("prevout_hash"), inp.get("prevout_n"), inp.get("address"), inp.get("value"), ) if not prevout_hash or n is None: raise RuntimeError("Missing prevout_hash and/or prevout_n") if typ != "coinbase" and ( not isinstance(addr, Address) or value is None ): tx = cls.tx_cache_get(prevout_hash) or wallet.transactions.get( prevout_hash ) if tx: # Tx was in cache or wallet.transactions, proceed # note that the tx here should be in the "not # deserialized" state if tx.raw: # Note we deserialize a *copy* of the tx so as to # save memory. We do not want to deserialize the # cached tx because if we do so, the cache will # contain a deserialized tx which will take up # several times the memory when deserialized due to # Python's memory use being less efficient than the # binary-only raw bytes. So if you modify this code # do bear that in mind. tx = Transaction(tx.raw) try: tx.deserialize() # The below txid check is commented-out as # we trust wallet tx's and the network # tx's that fail this check are never # put in cache anyway. # txid = tx._txid(tx.raw) # if txid != prevout_hash: # sanity check # print_error("fetch_input_data: cached prevout_hash {} != tx.txid() {}, ignoring.".format(prevout_hash, txid)) except Exception as e: print_error( "fetch_input_data: WARNING failed to deserialize" " {}: {}".format(prevout_hash, repr(e)) ) tx = None else: tx = None print_error( "fetch_input_data: WARNING cached tx lacked any 'raw'" " bytes for {}".format(prevout_hash) ) # now, examine the deserialized tx, if it's still good if tx: if n < len(tx.outputs()): outp = tx.outputs()[n] addr, value = outp[1], outp[2] inp["value"] = value inp["address"] = addr print_error( "fetch_input_data: fetched cached", i, addr, value ) else: print_error( "fetch_input_data: ** FIXME ** should never happen --" " n={} >= len(tx.outputs())={} for prevout {}".format( n, len(tx.outputs()), prevout_hash ) ) else: # tx was not in cache or wallet.transactions, mark # it for download below (this branch can also execute # in the unlikely case where there was an error above) need_dl_txids[prevout_hash].append( (i, n) ) # remember the input# as well as the prevout_n inps.append( inp ) # append either cached result or as-yet-incomplete copy of _inputs[i] # Now, download the tx's we didn't find above if network is available # and caller said it's ok to go out ot network.. otherwise just return # what we have if use_network and eph.get("_fetch") == t and wallet.network: callback_funcs_to_cancel = set() try: # the whole point of this try block is the `finally` way below... prog(-1) # tell interested code that progress is now 0% # Next, queue the transaction.get requests, spreading them # out randomly over the connected interfaces q = queue.Queue() q_ct = 0 bad_txids = set() def put_in_queue_and_cache(r): """we cache the results directly in the network callback as even if the user cancels the operation, we would like to save the returned tx in our cache, since we did the work to retrieve it anyway.""" q.put(r) # put the result in the queue no matter what it is txid = "" try: # Below will raise if response was 'error' or # otherwise invalid. Note: for performance reasons # we don't validate the tx here or deserialize it as # this function runs in the network thread and we # don't want to eat up that thread's CPU time # needlessly. Also note the cache doesn't store # deserializd tx's so as to save memory. We # always deserialize a copy when reading the cache. tx = Transaction(r["result"]) txid = r["params"][0] assert txid == cls._txid( tx.raw ), ( # protection against phony responses "txid-is-sane-check" ) cls.tx_cache_put(tx=tx, txid=txid) # save tx to cache here except Exception as e: # response was not valid, ignore (don't cache) if ( txid ): # txid may be '' if KeyError from r['result'] above bad_txids.add(txid) print_error( ( "fetch_input_data: put_in_queue_and_cache fail for" " txid:" ), txid, repr(e), ) for txid, l in need_dl_txids.items(): wallet.network.queue_request( "blockchain.transaction.get", [txid], interface="random", callback=put_in_queue_and_cache, ) callback_funcs_to_cancel.add(put_in_queue_and_cache) q_ct += 1 def get_bh(): if eph.get("block_height"): return False lh = ( wallet.network.get_server_height() or wallet.get_local_height() ) def got_tx_info(r): q.put( "block_height" ) # indicate to other thread we got the block_height reply from network try: # will raise of error reply confs = r.get("result").get("confirmations", 0) if confs and lh: # the whole point.. was to get this piece of data.. the block_height eph["block_height"] = bh = lh - confs + 1 print_error( "fetch_input_data: got tx block height", bh ) else: print_error( "fetch_input_data: tx block height could not be" " determined" ) except Exception as e: print_error("fetch_input_data: get_bh fail:", str(e), r) if self_txid: wallet.network.queue_request( "blockchain.transaction.get", [self_txid, True], interface=None, callback=got_tx_info, ) callback_funcs_to_cancel.add(got_tx_info) return True if get_bh(): q_ct += 1 class ErrorResp(Exception): pass for i in range(q_ct): # now, read the q back, with a 10 second timeout, and # populate the inputs try: r = q.get(timeout=10) if eph.get("_fetch") != t: # early abort from func, canceled break if r == "block_height": # ignore block_height reply from network.. was already processed in other thread in got_tx_info above continue if r.get("error"): msg = r.get("error") if isinstance(msg, dict): msg = msg.get("message") or "unknown error" raise ErrorResp(msg) rawhex = r["result"] txid = r["params"][0] assert ( txid not in bad_txids ), ( # skip if was marked bad by our callback code "txid marked bad" ) tx = Transaction(rawhex) tx.deserialize() for item in need_dl_txids[txid]: ii, n = item assert n < len(tx.outputs()) outp = tx.outputs()[n] addr, value = outp[1], outp[2] inps[ii]["value"] = value inps[ii]["address"] = addr print_error( "fetch_input_data: fetched from network", ii, addr, value, ) prog(i, q_ct) # tell interested code of progress except queue.Empty: print_error( "fetch_input_data: timed out after 10.0s fetching from" " network, giving up." ) break except Exception as e: print_error("fetch_input_data:", repr(e)) finally: # force-cancel any extant requests -- this is especially # crucial on error/timeout/failure. for func in callback_funcs_to_cancel: wallet.network.cancel_requests(func) # sanity check if len(inps) == len(self._inputs) and eph.get("_fetch") == t: # potential race condition here, popping wrong t -- but in practice w/ # CPython threading it won't matter eph.pop("_fetch", None) print_error(f"fetch_input_data: elapsed {(time.time()-t0):.4f} sec") if done_callback: done_callback(*done_args) # /doIt t = threading.Thread(target=doIt, daemon=True) eph["_fetch"] = t t.start() return True def fetched_inputs(self, *, require_complete=False): """Returns the complete list of asynchronously fetched inputs for this tx, if they exist. If the list is not yet fully retrieved, and require_complete == False, returns what it has so far (the returned list will always be exactly equal to len(self._inputs), with not-yet downloaded inputs coming from self._inputs and not necessarily containing a good 'address' or 'value'). If the download failed completely or was never started, will return the empty list []. Note that some inputs may still lack key: 'value' if there was a network error in retrieving them or if the download is still in progress.""" if self._inputs: ret = self.ephemeral.get("fetched_inputs") or [] diff = len(self._inputs) - len(ret) if diff > 0 and self.ephemeral.get("_fetch") and not require_complete: # in progress.. so return what we have so far return ret + self._inputs[len(ret) :] elif diff == 0 and ( not require_complete or not self.ephemeral.get("_fetch") ): # finished *or* in-progress and require_complete==False return ret return [] def fetch_cancel(self) -> bool: """Cancels the currently-active running fetch operation, if any""" return bool(self.ephemeral.pop("_fetch", None)) @classmethod def tx_cache_get(cls, txid: str) -> object: """Attempts to retrieve txid from the tx cache that this class keeps in-memory. Returns None on failure. The returned tx is not deserialized, and is a copy of the one in the cache.""" tx = cls._fetched_tx_cache.get(txid) if tx is not None and tx.raw: # make sure to return a copy of the transaction from the cache # so that if caller does .deserialize(), *his* instance will # use up 10x memory consumption, and not the cached instance which # should just be an undeserialized raw tx. return Transaction(tx.raw) return None @classmethod def tx_cache_put(cls, tx: object, txid: Optional[str] = None): """Puts a non-deserialized copy of tx into the tx_cache.""" if not tx or not tx.raw: raise ValueError("Please pass a tx which has a valid .raw attribute!") txid = txid or cls._txid( tx.raw ) # optionally, caller can pass-in txid to save CPU time for hashing cls._fetched_tx_cache.put(txid, Transaction(tx.raw)) def tx_from_str(txt): "json or raw hexadecimal" import json txt = txt.strip() if not txt: raise ValueError("empty string") try: bfh(txt) is_hex = True except Exception: is_hex = False if is_hex: return txt tx_dict = json.loads(str(txt)) assert "hex" in tx_dict.keys() return tx_dict["hex"] # --- class OPReturn: """OPReturn helper namespace. Used by GUI main_window.py and also commands.py""" class Error(Exception): """thrown when the OP_RETURN for a tx not of the right format""" class TooLarge(Error): """thrown when the OP_RETURN for a tx is >220 bytes""" @staticmethod def output_for_stringdata(op_return): from .i18n import _ if not isinstance(op_return, str): raise OPReturn.Error("OP_RETURN parameter needs to be of type str!") op_return_code = "OP_RETURN " op_return_encoded = op_return.encode("utf-8") if len(op_return_encoded) > 220: raise OPReturn.TooLarge( _("OP_RETURN message too large, needs to be no longer than 220 bytes") ) op_return_payload = op_return_encoded.hex() script = op_return_code + op_return_payload amount = 0 return TxOutput(bitcoin.TYPE_SCRIPT, ScriptOutput.from_string(script), amount) @staticmethod def output_for_rawhex(op_return): from .i18n import _ if not isinstance(op_return, str): raise OPReturn.Error("OP_RETURN parameter needs to be of type str!") if op_return == "empty": op_return = "" try: op_return_script = b"\x6a" + bytes.fromhex(op_return.strip()) except ValueError: raise OPReturn.Error(_("OP_RETURN script expected to be hexadecimal bytes")) if len(op_return_script) > 223: raise OPReturn.TooLarge( _("OP_RETURN script too large, needs to be no longer than 223 bytes") ) amount = 0 return TxOutput( bitcoin.TYPE_SCRIPT, ScriptOutput.protocol_factory(op_return_script), amount, ) # /OPReturn diff --git a/electrum/electrumabc/util.py b/electrum/electrumabc/util.py index 3608dcc66..80084e2f9 100644 --- a/electrum/electrumabc/util.py +++ b/electrum/electrumabc/util.py @@ -1,1053 +1,1053 @@ # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2011 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. import binascii import builtins import hmac import inspect import json import locale import os import re import socket import ssl import stat import subprocess import sys import threading import time import traceback import weakref from abc import ABC, abstractmethod from collections import defaultdict from datetime import datetime from decimal import Decimal from .caches import ExpiringCache from .constants import POSIX_DATA_DIR, PROJECT_NAME_NO_SPACES from .printerror import PrintError, print_error, print_stderr # https://docs.python.org/3/library/gettext.html#deferred-translations def _(message): return message fee_levels = [ _("Within 25 blocks"), _("Within 10 blocks"), _("Within 5 blocks"), _("Within 2 blocks"), _("In the next block"), ] del _ from .i18n import _, ngettext # noqa: E402 def inv_dict(d): return {v: k for k, v in d.items()} class NotEnoughFunds(Exception): pass class ExcessiveFee(Exception): pass class InvalidPassword(Exception): def __str__(self): return _("Incorrect password") class FileImportFailed(Exception): def __str__(self): return _("Failed to import file.") class FileImportFailedEncrypted(FileImportFailed): def __str__(self): return ( _("Failed to import file.") + " " + _("Perhaps it is encrypted...") + "\n" + _("Importing encrypted files is not supported.") ) class WalletFileException(Exception): pass class BitcoinException(Exception): pass # Throw this exception to unwind the stack like when an error occurs. # However unlike other exceptions the user won't be informed. class UserCancelled(Exception): """An exception that is suppressed from the user""" pass class MyEncoder(json.JSONEncoder): def default(self, obj): from .transaction import Transaction if isinstance(obj, Transaction): return obj.as_dict() if isinstance(obj, datetime): return obj.isoformat(" ")[:-3] if isinstance(obj, set): return list(obj) return super(MyEncoder, self).default(obj) class ThreadJob(ABC, PrintError): """A job that is run periodically from a thread's main loop. run() is called from that thread's context. """ @abstractmethod def run(self): """Called periodically from the thread""" class DebugMem(ThreadJob): """A handy class for debugging GC memory leaks""" def __init__(self, classes, interval=30): self.next_time = 0 self.classes = classes self.interval = interval def mem_stats(self): import gc self.print_error("Start memscan") gc.collect() objmap = defaultdict(list) for obj in gc.get_objects(): for class_ in self.classes: if isinstance(obj, class_): objmap[class_].append(obj) for class_, objs in objmap.items(): self.print_error("%s: %d" % (class_.__name__, len(objs))) self.print_error("Finish memscan") def run(self): if time.time() > self.next_time: self.mem_stats() self.next_time = time.time() + self.interval class DaemonThread(threading.Thread, PrintError): """daemon thread that terminates cleanly""" def __init__(self): threading.Thread.__init__(self) self.parent_thread = threading.current_thread() self.running = False self.running_lock = threading.Lock() self.job_lock = threading.Lock() # could use a set here but order is important, so we enforce uniqueness in this # list in the add/remove methods self.jobs = [] # adding jobs needs to preserve order, so we use a list. self._jobs2add = list() # removing jobs does not need to preserve orer so we can benefit from the # uniqueness property of using a set. self._jobs2rm = set() def add_jobs(self, jobs): if threading.current_thread() is not self: with self.job_lock: for job in jobs: if job not in self.jobs: # ensure unique self.jobs.append(job) self.print_error("Job added", job) else: self.print_error("add_jobs: FIXME job already added", job) else: # support for adding/removing jobs from within the ThreadJob's .run self._jobs2rm.difference_update(jobs) self._jobs2add.extend(jobs) def remove_jobs(self, jobs): if threading.current_thread() is not self: with self.job_lock: for job in jobs: ct = 0 while job in self.jobs: # enfore unique jobs self.jobs.remove(job) ct += 1 self.print_error("Job removed", job) if not ct: self.print_error("remove_jobs: FIXME job not found", job) else: # support for adding/removing jobs from within the ThreadJob's .run for job in jobs: while job in self._jobs2add: # enforce uniqueness of jobs self._jobs2add.remove(job) self._jobs2rm.update(jobs) def run_jobs(self): with self.job_lock: for job in self.jobs: try: job.run() except Exception: # Don't let a throwing job disrupt the thread, future runs of # itself, or other jobs. This is useful protection against # malformed or malicious server responses traceback.print_exc(file=sys.stderr) # below is support for jobs adding/removing themselves # during their run implementation. for addjob in self._jobs2add: if addjob not in self.jobs: self.jobs.append(addjob) self.print_error("Job added", addjob) self._jobs2add.clear() for rmjob in self._jobs2rm: while rmjob in self.jobs: self.jobs.remove(rmjob) self.print_error("Job removed", rmjob) self._jobs2rm.clear() def start(self): with self.running_lock: self.running = True return threading.Thread.start(self) def is_running(self): with self.running_lock: return self.running and self.parent_thread.is_alive() def stop(self): with self.running_lock: self.running = False def on_stop(self): self.print_error("stopped") # Method decorator. To be used for calculations that will always # deliver the same result. The method cannot take any arguments # and should be accessed as an attribute. class cachedproperty: def __init__(self, f): self.f = f - def __get__(self, obj, type): - obj = obj or type + def __get__(self, obj, type_): + obj = obj or type_ value = self.f(obj) setattr(obj, self.f.__name__, value) return value def json_encode(obj): try: s = json.dumps(obj, sort_keys=True, indent=4, cls=MyEncoder) except TypeError: s = repr(obj) return s def json_decode(x): try: return json.loads(x, parse_float=Decimal) except Exception: return x # taken from Django Source Code def constant_time_compare(val1, val2): """Return True if the two strings are equal, False otherwise.""" return hmac.compare_digest(to_bytes(val1, "utf8"), to_bytes(val2, "utf8")) # decorator that prints execution time def profiler(func): def do_profile(args, kw_args): t0 = time.time() o = func(*args, **kw_args) t = time.time() - t0 print_error("[profiler]", func.__qualname__, "%.4f" % t) return o return lambda *args, **kw_args: do_profile(args, kw_args) def ensure_sparse_file(filename): if os.name == "nt": try: subprocess.call('fsutil sparse setFlag "' + filename + '" 1', shell=True) except Exception: pass def get_headers_dir(config): return config.path def assert_datadir_available(config_path): path = config_path if os.path.exists(path): return else: raise FileNotFoundError( "Datadir does not exist. Was it deleted while running?" + "\n" + "Should be at {}".format(path) ) def assert_file_in_datadir_available(path, config_path): if os.path.exists(path): return else: assert_datadir_available(config_path) raise FileNotFoundError( "Cannot find file but datadir is there." + "\n" + "Should be at {}".format(path) ) def standardize_path(path): if path is not None: path = os.path.normcase(os.path.realpath(os.path.abspath(path))) return path def get_new_wallet_name(wallet_folder: str) -> str: i = 1 while True: filename = "wallet_%d" % i if os.path.exists(os.path.join(wallet_folder, filename)): i += 1 else: break return filename def assert_bytes(*args): """ porting helper, assert args type """ try: for x in args: assert isinstance(x, (bytes, bytearray)) except Exception: print("assert bytes failed", list(map(type, args))) raise def assert_str(*args): """ porting helper, assert args type """ for x in args: assert isinstance(x, str) def to_string(x, enc="utf8"): if isinstance(x, (bytes, bytearray)): return x.decode(enc) if isinstance(x, str): return x else: raise TypeError("Not a string or bytes like object") def to_bytes(something, encoding="utf8"): """ cast string to bytes() like object, but for python2 support it's bytearray copy """ if isinstance(something, bytes): return something if isinstance(something, str): return something.encode(encoding) elif isinstance(something, bytearray): return bytes(something) else: raise TypeError("Not a string or bytes like object") bfh = bytes.fromhex hfu = binascii.hexlify def bh2u(x): """ str with hex representation of a bytes-like object >>> x = bytes((1, 2, 10)) >>> bh2u(x) '01020a' :param x: bytes :rtype: str """ return hfu(x).decode("ascii") def get_user_dir(prefer_local=False): if os.name == "posix" and "HOME" in os.environ: return os.path.join(os.environ["HOME"], POSIX_DATA_DIR) elif "APPDATA" in os.environ or "LOCALAPPDATA" in os.environ: app_dir = os.environ.get("APPDATA") localapp_dir = os.environ.get("LOCALAPPDATA") # Prefer APPDATA, but may get LOCALAPPDATA if present and req'd. if localapp_dir is not None and prefer_local or app_dir is None: app_dir = localapp_dir return os.path.join(app_dir, PROJECT_NAME_NO_SPACES) else: # raise Exception("No home directory found in environment variables.") return def make_dir(path): # Make directory if it does not yet exist. if not os.path.exists(path): if os.path.islink(path): raise RuntimeError("Dangling link: " + path) os.mkdir(path) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) def format_satoshis_plain(x, decimal_point=8): """Display a satoshi amount scaled. Always uses a '.' as a decimal point and has no thousands separator""" if x is None: return _("Unknown") scale_factor = pow(10, decimal_point) return "{:.8f}".format(Decimal(x) / scale_factor).rstrip("0").rstrip(".") _cached_dp = None LOCALE_HAS_THOUSANDS_SEPARATOR = None def clear_cached_dp(): """This function allows to reset the cached locale decimal point. This is used for testing amount formatting with various locales.""" global _cached_dp _cached_dp = None def set_locale_has_thousands_separator(flag: bool): global LOCALE_HAS_THOUSANDS_SEPARATOR LOCALE_HAS_THOUSANDS_SEPARATOR = flag # This cache will eat about ~6MB of memory per 20,000 items, but it does make # format_satoshis() run over 3x faster. _fmt_sats_cache = ExpiringCache(maxlen=20000, name="format_satoshis cache") def format_satoshis( x, num_zeros=0, decimal_point=2, precision=None, is_diff=False, whitespaces=False ) -> str: global _cached_dp # We lazy init this here rather than at module level in case the # locale is not set at program startup when the module is first # imported. if LOCALE_HAS_THOUSANDS_SEPARATOR is None: try: # setting the local to the system's default work for Windows, # Linux. On Mac OS, it sometimes works, but sometimes fails. locale.setlocale(locale.LC_NUMERIC, "") except locale.Error: set_locale_has_thousands_separator(False) else: set_locale_has_thousands_separator(len(f"{1000:n}") > 4) if _cached_dp is None: if not LOCALE_HAS_THOUSANDS_SEPARATOR: # We will use python's locale-unaware way of formatting numbers # with thousands separators, using a "." for decimal point. _cached_dp = "." else: _cached_dp = locale.localeconv().get("decimal_point") or "." if x is None: return _("Unknown") if precision is None: precision = decimal_point cache_key = (x, num_zeros, decimal_point, precision, is_diff, whitespaces) result = _fmt_sats_cache.get(cache_key) if result is not None: return result try: value = x / pow(10, decimal_point) except ArithmeticError: # Normally doesn't happen but if x is a huge int, we may get # OverflowError or other ArithmeticError subclass exception. # See Electron-Cash#1024. # TODO: this happens only on user input, so just add a range # validator on the wiget return "unknown" if LOCALE_HAS_THOUSANDS_SEPARATOR: decimal_format = ".0" + str(precision) if precision > 0 else "" if is_diff: decimal_format = "+" + decimal_format decimal_format = "%" + decimal_format + "f" result = locale.format_string(decimal_format, value, grouping=True).rstrip("0") else: # default to ts="," and dp=".", with python local-unaware formatting decimal_format = "{:" if is_diff: decimal_format += "+" decimal_format += "," if precision > 0: decimal_format += ".0" + str(precision) decimal_format += "f}" result = decimal_format.format(value).rstrip("0") dp = _cached_dp if dp in result: integer_part, fract_part = result.split(dp) else: integer_part, fract_part = result, "" if len(fract_part) < num_zeros: fract_part += "0" * (num_zeros - len(fract_part)) result = integer_part + dp + fract_part if whitespaces: result += " " * (decimal_point - len(fract_part)) result = " " * (15 - len(result)) + result _fmt_sats_cache.put(cache_key, result) return result def format_fee_satoshis(fee, num_zeros=0): return format_satoshis(fee, num_zeros, 0, precision=num_zeros) def timestamp_to_datetime(timestamp): try: return datetime.fromtimestamp(timestamp) except Exception: return None def format_time(timestamp): if timestamp: date = timestamp_to_datetime(timestamp) if date: return date.isoformat(" ")[:-3] return _("Unknown") # Takes a timestamp and returns a string with the approximation of the age def age(from_date, since_date=None, target_tz=None, include_seconds=False): if from_date is None: return _("Unknown") try: from_date = datetime.fromtimestamp(from_date) if since_date is None: since_date = datetime.now(target_tz) else: if isinstance(since_date, (int, float)): since_date = datetime.fromtimestamp(since_date) except ValueError: return _("Error") td = time_difference(from_date - since_date, include_seconds) if from_date < since_date: return _("{time} ago").format(time=td) else: return _("in {time}").format(time=td) def time_difference(distance_in_time, include_seconds): # distance_in_time = from_date - since_date distance_in_seconds = int( round(abs(distance_in_time.days * 86400 + distance_in_time.seconds)) ) distance_in_minutes = int(round(distance_in_seconds / 60)) if distance_in_seconds < 60: if include_seconds: for remainder in [5, 10, 20]: if distance_in_seconds < remainder: return _("less than {seconds} seconds").format(seconds=remainder) if distance_in_seconds < 40: return _("half a minute") else: return _("about a minute") else: return _("less than a minute") elif distance_in_seconds < 90: return _("about a minute") elif distance_in_minutes < 45: fmt = ngettext("{minutes} minute", "{minutes} minutes", distance_in_minutes) return fmt.format(minutes=distance_in_minutes) elif distance_in_minutes < 90: return _("about 1 hour") elif distance_in_minutes < 1440: distance_in_hours = round(distance_in_minutes / 60.0) fmt = ngettext("{hours} hour", "{hours} hours", distance_in_hours) return fmt.format(hours=distance_in_hours) elif distance_in_minutes < 2160: return _("about 1 day") elif distance_in_minutes < 43220: distance_in_days = round(distance_in_minutes / 1440.0) fmt = ngettext("{days} day", "{days} days", distance_in_days) return fmt.format(days=distance_in_days) elif distance_in_minutes < 64830: return _("about 1 month") elif distance_in_minutes < 525600: distance_in_months = round(distance_in_minutes / 43200.0) fmt = ngettext("{months} month", "{months} months", distance_in_months) return fmt.format(months=distance_in_months) elif distance_in_minutes < 788400: return _("about 1 year") else: distance_in_years = round(distance_in_minutes / 525600.0) fmt = ngettext("{years} year", "{years} years", distance_in_years) return fmt.format(years=distance_in_years) # Python bug (http://bugs.python.org/issue1927) causes raw_input # to be redirected improperly between stdin/stderr on Unix systems # TODO: py3 def raw_input(prompt=None): if prompt: sys.stdout.write(prompt) return builtin_raw_input() builtin_raw_input = builtins.input builtins.input = raw_input def parse_json(message): # TODO: check \r\n pattern n = message.find(b"\n") if n == -1: return None, message try: j = json.loads(message[0:n].decode("utf8")) except Exception: # just consume the line and ignore error. j = None return j, message[n + 1 :] class timeout(Exception): """Server timed out on broadcast tx (normally due to a bad connection). Exception string is the translated error string.""" pass TimeoutException = timeout # Future compat. with Electrum codebase/cherrypicking class ServerError(Exception): """Note exception string is the translated, gui-friendly error message. self.server_msg may be a dict or a string containing the raw response from the server. Do NOT display self.server_msg in GUI code due to potential for phishing attacks from the untrusted server. See: https://github.com/spesmilo/electrum/issues/4968""" def __init__(self, msg, server_msg=None): super().__init__(msg) self.server_msg = server_msg or "" # prefer empty string if none supplied class ServerErrorResponse(ServerError): """Raised by network.py broadcast_transaction2() when the server sent an error response. The actual server error response is contained in a dict and/or str in self.server_msg. Warning: DO NOT display the server text. Displaying server text harbors a phishing risk. Instead, a translated GUI-friendly 'deduced' response is in the exception string. See: https://github.com/spesmilo/electrum/issues/4968""" pass class TxHashMismatch(ServerError): """Raised by network.py broadcast_transaction2(). Server sent an OK response but the txid it supplied does not match our signed tx id that we requested to broadcast. The txid returned is stored in self.server_msg. It's advised not to display the txid response as there is also potential for phishing exploits if one does. Instead, the exception string contains a suitable translated GUI-friendly error message.""" pass class JSONSocketPipe(PrintError): """Non-blocking wrapper for a socket passing one-per-line json messages: ... Correctly handles SSL sockets and gives useful info for select loops. """ class Closed(RuntimeError): """Raised if socket is closed""" def __init__(self, socket, *, max_message_bytes=0): """A max_message_bytes of <= 0 means unlimited, otherwise a positive value indicates this many bytes to limit the message size by. This is used by get(), which will raise MessageSizeExceeded if the message size received is larger than max_message_bytes.""" self.socket = socket socket.settimeout(0) self.recv_time = time.time() self.max_message_bytes = max_message_bytes self.recv_buf = bytearray() self.send_buf = bytearray() def idle_time(self): return time.time() - self.recv_time def get_selectloop_info(self): """Returns tuple: read_pending - new data is available that may be unknown to select(), so perform a get() regardless of select(). write_pending - some send data is still buffered, so make sure to call send_flush if writing becomes available. """ try: # pending() only defined on SSL sockets. has_pending = self.socket.pending() > 0 except AttributeError: has_pending = False return has_pending, bool(self.send_buf) def get(self): """Attempt to read out a message, possibly saving additional messages in a receive buffer. If no message is currently available, this raises util.timeout and you should retry once data becomes available to read. If connection is bad for some known reason, raises .Closed; other errors will raise other exceptions. """ while True: response, self.recv_buf = parse_json(self.recv_buf) if response is not None: return response try: data = self.socket.recv(1024) except (socket.timeout, BlockingIOError, ssl.SSLWantReadError): raise timeout except OSError as exc: if exc.errno in (11, 35, 60, 10035): # some OSes might give these ways of indicating a would-block error. raise timeout if exc.errno == 9: # EBADF. Someone called close() locally so FD is bad. raise self.Closed("closed by local") raise self.Closed( "closing due to {}: {}".format(type(exc).__name__, str(exc)) ) if not data: raise self.Closed("closed by remote") self.recv_buf.extend(data) self.recv_time = time.time() if ( self.max_message_bytes > 0 and len(self.recv_buf) > self.max_message_bytes ): raise self.Closed( f"Message limit is: {self.max_message_bytes}; receive buffer" " exceeded this limit!" ) def send(self, request): out = json.dumps(request) + "\n" out = out.encode("utf8") self.send_buf.extend(out) return self.send_flush() def send_all(self, requests): out = b"".join(map(lambda x: (json.dumps(x) + "\n").encode("utf8"), requests)) self.send_buf.extend(out) return self.send_flush() def send_flush(self): """Flush any unsent data from a prior call to send / send_all. Raises timeout if more data remains to be sent. Raise .Closed in the event of a socket error that requires abandoning this socket. """ send_buf = self.send_buf while send_buf: try: sent = self.socket.send(send_buf) except (socket.timeout, BlockingIOError, ssl.SSLWantWriteError): raise timeout except OSError as exc: if exc.errno in (11, 35, 60, 10035): # some OSes might give these ways of indicating a would-block error. raise timeout if exc.errno == 9: # EBADF. Someone called close() locally so FD is bad. raise self.Closed("closed by local") raise self.Closed( "closing due to {}: {}".format(type(exc).__name__, str(exc)) ) if sent == 0: # shouldn't happen, but just in case, we don't want to infinite # loop. raise timeout del send_buf[:sent] def setup_thread_excepthook(): """ Workaround for `sys.excepthook` thread bug from: http://bugs.python.org/issue1230540 Call once from the main thread before creating any threads. """ init_original = threading.Thread.__init__ def init(self, *args, **kwargs): init_original(self, *args, **kwargs) run_original = self.run def run_with_except_hook(*args2, **kwargs2): try: run_original(*args2, **kwargs2) except Exception: sys.excepthook(*sys.exc_info()) self.run = run_with_except_hook threading.Thread.__init__ = init def versiontuple(v): """Please do not use this function as it breaks with EC version styles of things like '3.3.4CS'. Instead, use version.parse_package_version""" return tuple(map(int, (v.split(".")))) class Handlers: """A place to put app-global handlers. Currently the "do_in_main_thread_handler" lives here""" @staticmethod def default_do_in_main_thread_handler(func, *args, **kwargs): """The default "do_in_main_thread_handler" simply immediately calls func, but it does print a warning if the current thread is not the main thread.""" this_thread = threading.current_thread() if this_thread is not threading.main_thread(): print_stderr( ( "Warning: do_in_main_thread called with the default handler" f" from outside the main thread (thr: {this_thread.name});" " such usage may lead to undefined behavior. Traceback:\n" ), "".join(traceback.format_stack()), ) func(*args, **kwargs) # GUI subsystems that wish to use `do_in_main_thread` (defined below) must # register a handler by setting this class-level attribute. See # ElectrumGui._setup_do_in_main_thread_handler in gui/qt/__init__py for an # example of how this is done for Qt. do_in_main_thread = default_do_in_main_thread_handler def do_in_main_thread(func, *args, **kwargs): """Calls func(*args, **kwargs) in the main thread, or immediately if the calling context *is* the main thread. Note that for this to work the GUI system in question must install a handler for this mechanism (if it has an event loop that is!) and set the global Handlers.do_in_main_thread = someFunc() to actually post the invocation to the main thread. The default handler immediately invokes func, but it does print a warning if the current thread is not the main thread""" if threading.current_thread() is threading.main_thread(): func(*args, **kwargs) else: Handlers.do_in_main_thread(func, *args, **kwargs) def in_main_thread(func): """ Function decorator that runs the decorated function in the main thread. """ def wrapper(*args, **kwargs): do_in_main_thread(func, *args, **kwargs) return wrapper class Weak: """ Weak reference factory. Create either a weak proxy to a bound method or a weakref.proxy, depending on whether this factory class's __new__ is invoked with a bound method or a regular function/object as its first argument. If used with an object/function reference this factory just creates a weakref.proxy and returns that. myweak = Weak(myobj) type(myweak) == weakref.proxy # <-- True The interesting usage is when this factory is used with a bound method instance. In which case it returns a MethodProxy which behaves like a proxy to a bound method in that you can call the MethodProxy object directly: mybound = Weak(someObj.aMethod) mybound(arg1, arg2) # <-- invokes someObj.aMethod(arg1, arg2) This is unlike regular weakref.WeakMethod which is not a proxy and requires unsightly `foo()(args)`, or perhaps `foo() and foo()(args)` idioms. Also note that no exception is raised with MethodProxy instances when calling them on dead references. Instead, if the weakly bound method is no longer alive (because its object died), the situation is ignored as if no method were called (with an optional print facility provided to print debug information in such a situation). The optional `print_func` class attribute can be set in MethodProxy globally or for each instance specifically in order to specify a debug print function (which will receive exactly two arguments: the MethodProxy instance and an info string), so you can track when your weak bound method is being called after its object died (defaults to `print_error`). Note you may specify a second postional argument to this factory, `callback`, which is identical to the `callback` argument in the weakref documentation and will be called on target object finalization (destruction). This usage/idiom is intented to be used with Qt's signal/slots mechanism to allow for Qt bound signals to not prevent target objects from being garbage collected due to reference cycles -- hence the permissive, exception-free design.""" def __new__(cls, obj_or_bound_method, *args, **kwargs): if inspect.ismethod(obj_or_bound_method): # is a method -- use our custom proxy class return cls.MethodProxy(obj_or_bound_method, *args, **kwargs) else: # Not a method, just return a weakref.proxy return weakref.proxy(obj_or_bound_method, *args, **kwargs) ref = weakref.ref # alias for convenience so you don't have to import weakref Set = weakref.WeakSet # alias for convenience ValueDictionary = weakref.WeakValueDictionary # alias for convenience KeyDictionary = weakref.WeakKeyDictionary # alias for convenience Method = weakref.WeakMethod # alias finalize = weakref.finalize # alias _weak_refs_for_print_error = defaultdict(list) @staticmethod def finalization_print_error(obj, msg=None): """Supply a message to be printed via print_error when obj is finalized (Python GC'd). This is useful for debugging memory leaks.""" assert not isinstance( obj, type ), "finaliztion_print_error can only be used on instance objects!" if msg is None: if isinstance(obj, PrintError): name = obj.diagnostic_name() else: name = obj.__class__.__qualname__ msg = "[{}] finalized".format(name) def finalizer(x): wrs = Weak._weak_refs_for_print_error msgs = wrs.get(x, []) for m in msgs: print_error(m) wrs.pop(x, None) wr = Weak.ref(obj, finalizer) Weak._weak_refs_for_print_error[wr].append(msg) class MethodProxy(weakref.WeakMethod): """Direct-use of this class is discouraged (aside from assigning to its print_func attribute). Instead use of the wrapper class 'Weak' defined in the enclosing scope is encouraged.""" def __init__(self, meth, *args, **kwargs): super().__init__(meth, *args, **kwargs) # teehee.. save some information about what to call this thing for debug # print purposes self.qname, self.sname = meth.__qualname__, str(meth.__self__) def __call__(self, *args, **kwargs): """Either directly calls the method for you or prints debug info if the target object died""" # if dead, None is returned meth = super().__call__() if meth: return meth(*args, **kwargs) else: print_error( self, ( f"MethodProxy for '{self.qname}' called on a dead reference. " f"Referent was: {self.sname})" ), ) # Export this method to the top level for convenience. People reading code # may wonder 'Why Weak.finaliztion_print_error'?. The fact that this relies on # weak refs is an implementation detail, really. finalization_print_error = Weak.finalization_print_error def multisig_type(wallet_type): """If wallet_type is mofn multi-sig, return [m, n], otherwise return None.""" if not wallet_type: return None match = re.match(r"(\d+)of(\d+)", wallet_type) if match: match = [int(x) for x in match.group(1, 2)] return match diff --git a/electrum/electrumabc_gui/qt/main_window.py b/electrum/electrumabc_gui/qt/main_window.py index 2a152bac4..5e0ba65be 100644 --- a/electrum/electrumabc_gui/qt/main_window.py +++ b/electrum/electrumabc_gui/qt/main_window.py @@ -1,5518 +1,5516 @@ #!/usr/bin/env python3 # # 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, List, Optional 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.bitcoin import TYPE_ADDRESS from electrumabc.constants import CURRENCY, PROJECT_NAME, REPOSITORY_URL, SCRIPT_NAME from electrumabc.contacts import Contact from electrumabc.i18n import _, ngettext from electrumabc.paymentrequest import PR_PAID from electrumabc.plugins import run_hook from electrumabc.simple_config import get_config from electrumabc.transaction import ( OPReturn, SerializationError, Transaction, TxOutput, tx_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 .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 .util import ( MONOSPACE_FONT, Buttons, ButtonsLineEdit, CancelButton, ChoicesLayout, CloseButton, ColorScheme, CopyCloseButton, EnterButton, HelpButton, HelpLabel, MessageBoxMixin, MyTreeWidget, OkButton, RateLimiter, TaskThread, WaitingDialog, WindowModalDialog, WWLabel, address_combo, char_width_in_lineedit, destroyed_print_error, expiration_values, filename_field, 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 = {} 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 = ContactList(self) 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: from electrumabc.bitcoin import ( InvalidXKeyFormat, InvalidXKeyNotBase58, deserialize_xpub, ) 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)." ), "

", _( "In order to use this wallet without errors with this version" " of EC, please re-generate this wallet from seed." ), "

~SPV stopped~", ] ) 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.new_contact_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)." ) 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" 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): year_start_ec = 2017 year_end_ec = 2022 year_start = 2020 year_end = 2022 QtWidgets.QMessageBox.about( self, f"{PROJECT_NAME}", f"

{PROJECT_NAME}

" + _("Version") + f" {self.wallet.electrum_version}" + "

" + '

' + f"Copyright © {year_start}-{year_end} Bitcoin ABC and the {PROJECT_NAME} " "developers." + "

" + _( f"Copyright © {year_start_ec}-{year_end_ec} Electron Cash LLC " "and the Electron Cash developers." ) + "

" + _("darkdetect for macOS © 2019 Alberto Sottile") + "

" + '

' + _( 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." ) + "

", ) def show_report_bug(self): msg = " ".join( [ _("Please report any bugs as issues on github:
"), ( f'' f"{REPOSITORY_URL}/issues

" ), _( "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) # custom wrappers for getOpenFileName and getSaveFileName, that remember the path selected by the user - def getOpenFileName(self, title, filter=""): + def getOpenFileName(self, title, filter=""): # noqa: A002 return __class__.static_getOpenFileName( - title=title, filter=filter, config=self.config, parent=self + title=title, filtr=filter, config=self.config, parent=self ) - def getSaveFileName(self, title, filename, filter=""): + def getSaveFileName(self, title, filename, filter=""): # noqa: A002 return __class__.static_getSaveFileName( title=title, filename=filename, - filter=filter, + filtr=filter, config=self.config, parent=self, ) @staticmethod - def static_getOpenFileName(*, title, parent=None, config=None, filter=""): + def static_getOpenFileName(*, title, parent=None, config=None, filtr=""): if not config: config = get_config() userdir = os.path.expanduser("~") directory = config.get("io_dir", userdir) if config else userdir fileName, __ = QtWidgets.QFileDialog.getOpenFileName( - parent, title, directory, filter + parent, title, directory, filtr ) if fileName and directory != os.path.dirname(fileName) and config: config.set_key("io_dir", os.path.dirname(fileName), True) return fileName @staticmethod - def static_getSaveFileName(*, title, filename, parent=None, config=None, filter=""): + def static_getSaveFileName(*, title, filename, parent=None, config=None, filtr=""): if not config: config = get_config() userdir = os.path.expanduser("~") directory = config.get("io_dir", userdir) if config else userdir path = os.path.join(directory, filename) - fileName, __ = QtWidgets.QFileDialog.getSaveFileName( - parent, title, path, filter - ) + fileName, __ = QtWidgets.QFileDialog.getSaveFileName(parent, title, path, filtr) if fileName and directory != os.path.dirname(fileName) and config: config.set_key("io_dir", os.path.dirname(fileName), True) return fileName 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: OP_RETURN PUSH <text>. If checked, the text" " contents will be interpreted as a raw hexadecimal script to be" " appended after the OP_RETURN opcode: OP_RETURN" " <script>." ) ) 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 = list(map(lambda x: x[1], expiration_values))[i] 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 = self.getSaveFileName( _("Select where to save your payment request"), name, "*.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 = ( '' + _("Recipient of the funds.") + " " + _( "You may enter:" "
    " f"
  • {CURRENCY} Address " "
  • Bitcoin Legacy Address " "
  • Contact name from the Contacts tab" "
  • OpenAlias e.g. satoshi@domain.com" "

" "    = Supports pay-to-many, where" " you may optionally enter multiple lines of the form:" "

"
                 "    recipient1, amount1 \n"
                 "    recipient2, amount2 \n"
                 "    etc..."
                 "
" ) ) 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.setCompleter(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: OP_RETURN PUSH <text>. If checked, the text" " contents will be interpreted as a raw hexadecimal script to be" " appended after the OP_RETURN opcode: OP_RETURN" " <script>." ) ) 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( _( "

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.

" ) ) 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, ["", ""], 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(x): + 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(item), self.format_amount(item["value"])] + [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 assert _type == "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 + ":" ): line = line.split(":", 1)[1] # strip bitcoincash: prefix 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 + ":"): line = line.split(":", 1)[1] # strip "bitcoincash:" prefix 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) + "

" + _( "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(map(lambda x: x[2], 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, tx_desc, *, callback=None): def broadcast_thread(): # non-GUI thread status = False msg = "Failed" pr = self.payment_request if pr and pr.has_expired(): self.payment_request = None return False, _("Payment request has expired") if pr: 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 else: # Not a PR, just broadcast. status, msg = self.network.broadcast_transaction(tx) return status, msg # 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: pass # no fee info available for tx # 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 ( isinstance(fee, int) and tx.is_complete() and fee < len(str(tx)) // 2 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 # /end fee check # Capture current TL window; override might be removed on return parent = self.top_level_window() 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 elif not self.network.is_connected(): # Don't allow a potentially very slow broadcast when obviously not connected. parent.show_error(_("Not connected")) return def broadcast_done(result): # GUI thread cb_result = False if result: status, msg = result if status: cb_result = True buttons, copy_index, copy_link = [_("Ok")], None, "" try: txid = ( tx.txid() ) # returns None if not is_complete, but may raise potentially as well 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() else: if msg.startswith("error: "): msg = msg.split(" ", 1)[ -1 ] # take the last part, sans the "error: " prefix parent.show_error(msg) if callback: callback(cb_result) 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:] self.show_warning( 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)) ) # 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 self.show_error( _("Bad parameter: {bad_param_name}{extra_info}").format( bad_param_name=e.args[0], extra_info=extra_info ) ) 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 self.show_error(e.args[0] + ":\n\n" + ", ".join(e.args[1:])) return except Exception as e: self.show_error(_("Invalid bitcoincash URI:") + "\n\n" + str(e)) return self.show_send_tab() r = out.get("r") sig = out.get("sig") name = out.get("name") if r or (name and sig): self.prepare_for_payment_request() return address = out.get("address") amount = out.get("amount") 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 address or URI.strip().lower().split(":", 1)[0] in web.parseable_schemes(): # if address, set the payto field to the address. # 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(address or "") if message: self.message_e.setText(message) if amount: self.amount_e.setAmount(amount) self.amount_e.textEdited.emit("") 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) 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 set_contact( self, label, address, typ="address", replace=None ) -> Optional[Contact]: """Returns a reference to the newly inserted Contact object. replace is optional and if specified, replace an existing contact, otherwise add a new one. Note that duplicate contacts will not be added multiple times, but in that case the returned value would still be a valid Contact. Returns None on failure.""" assert typ == "address" if not Address.is_valid(address): self.show_error(_("Invalid Address")) self.contact_list.update() # Displays original unchanged value return contact = Contact(name=label, address=address, type=typ) if replace != contact: if self.contacts.has(contact): self.show_error( _( f"A contact named {contact.name} with the same address and type" " already exists." ) ) self.contact_list.update() return replace or contact self.contacts.add(contact, replace_old=replace, unique=True) self.contact_list.update() self.history_list.update() self.history_updated_signal.emit() # inform things like address_dialog that there's a new history self.update_completions() # The contact has changed, update any addresses that are displayed with the old information. run_hook("update_contact2", contact, replace) return contact def delete_contacts(self, contacts): names = [ f"{contact.name} <{contact.address[:8]}{'…' if len(contact.address) > 8 else ''}>" for contact in contacts ] n = len(names) contact_str = ( " + ".join(names) if n <= 3 else ngettext( "{number_of_contacts} contact", "{number_of_contacts} contacts", n ).format(number_of_contacts=n) ) if not self.question( _( "Remove {list_of_contacts_OR_count_of_contacts_plus_the_word_count}" " from your list of contacts?" ).format( list_of_contacts_OR_count_of_contacts_plus_the_word_count=contact_str ) ): return removed_entries = [] for contact in contacts: if self.contacts.remove(contact): removed_entries.append(contact) self.history_list.update() self.history_updated_signal.emit() # inform things like address_dialog that there's a new history self.contact_list.update() self.update_completions() run_hook("delete_contacts2", removed_entries) 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( map( lambda x: self.format_amount(x[2]) + self.base_unit() + " @ " + x[1].to_ui_string(), 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 = self.getSaveFileName(_("Save invoice to file"), "*." + ext) 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 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: self.show_error(str(e)) return except Exception: if util.is_verbose: traceback.print_exc(file=sys.stderr) self.show_error(_("Failed to update password")) 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 new_contact_dialog(self): d = WindowModalDialog(self.top_level_window(), _("New Contact")) vbox = QtWidgets.QVBoxLayout(d) vbox.addWidget(QtWidgets.QLabel(_("New Contact") + ":")) grid = QtWidgets.QGridLayout() line1 = QtWidgets.QLineEdit() line1.setFixedWidth(38 * char_width_in_lineedit()) line2 = QtWidgets.QLineEdit() line2.setFixedWidth(38 * char_width_in_lineedit()) grid.addWidget(QtWidgets.QLabel(_("Name")), 1, 0) grid.addWidget(line1, 1, 1) grid.addWidget(QtWidgets.QLabel(_("Address")), 2, 0) grid.addWidget(line2, 2, 1) vbox.addLayout(grid) vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) if d.exec_(): name = line1.text().strip() address = line2.text().strip() prefix = networks.net.CASHADDR_PREFIX.lower() + ":" if address.lower().startswith(prefix): address = address[len(prefix) :] self.set_contact(name, address) 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 util.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)) 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 util.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: encrypted = bitcoin.encrypt_message(message, pubkey_e.text()) encrypted_e.setText(encrypted.decode("ascii")) except Exception as e: if util.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) -> Optional[Transaction]: try: txt_tx = tx_from_str(txt) tx = Transaction(txt_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 i, txin in enumerate(tx.inputs()): outpoint = txin["prevout_hash"] + ":" + str(txin["prevout_n"]) if outpoint in my_outpoints: my_index = my_outpoints.index(outpoint) tx._inputs[i]["value"] = my_coins[my_index]["value"] return tx except Exception: if util.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 util.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 = self.getOpenFileName(_("Select your transaction file"), "*.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(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 = ( "".format( monoface=MONOSPACE_FONT ) + bip38 + ' {link}'.format(link=_("Hide")) ) else: pf_text = '{link}'.format( link=_("Click to show") ) wwlbl.setText( _( "The below keys are BIP38 encrypted using the" " passphrase: {passphrase}
Please write this passphrase" " down 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 = self.getOpenFileName(_("Open labels file"), "*.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 = self.getSaveFileName( _("Select file to save your labels"), f"{SCRIPT_NAME}_labels.json", "*.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: item["fiat_currency"] = ( 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 ) 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( _( "BIP38 support is disabled because a requisite library is not" " installed. 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 coins, keypairs = sweep_preparations(keys, self.network) self.tx_external_keypairs = keypairs self.payto_e.setText(get_address_text()) self.spend_coins(coins) 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) @protected def show_auxiliary_keys(self, password): if not self.wallet.is_deterministic() or not self.wallet.can_export(): return d = AuxiliaryKeysDialog(self.wallet, password, 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) # 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(map(lambda x: x[1], 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( "" + (title or "") + "" ) 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): # FIXME: check if this method still has any use, after cashacct removal parent = self.weakParent() if not parent or parent.cleaned_up: return if parent.network: txns = self.notifs_get_and_clear() if txns: # Combine the transactions n_ok, total_amount = 0, 0 for tx in txns: if tx: is_relevant, is_mine, v, fee = parent.wallet.get_wallet_delta( tx ) if not is_relevant: continue total_amount += v n_ok += 1 diff --git a/electrum/electrumabc_gui/qt/multi_transactions_dialog.py b/electrum/electrumabc_gui/qt/multi_transactions_dialog.py index 27951b52f..09a03bfc4 100644 --- a/electrum/electrumabc_gui/qt/multi_transactions_dialog.py +++ b/electrum/electrumabc_gui/qt/multi_transactions_dialog.py @@ -1,243 +1,243 @@ import json from pathlib import Path from typing import Sequence from PyQt5 import QtGui, QtWidgets from electrumabc import transaction from electrumabc.bitcoin import sha256 from electrumabc.constants import XEC from electrumabc.wallet import AbstractWallet from .util import MessageBoxMixin class MultiTransactionsWidget(QtWidgets.QWidget, MessageBoxMixin): """Display multiple transactions, with statistics and tools (sign, broadcast...)""" def __init__(self, wallet, main_window, parent=None): super().__init__(parent) self.setMinimumWidth(800) self.wallet: AbstractWallet = wallet self.transactions: Sequence[transaction.Transaction] = [] self.main_window = main_window layout = QtWidgets.QVBoxLayout() self.setLayout(layout) self.num_tx_label = QtWidgets.QLabel() layout.addWidget(self.num_tx_label) self.in_value_label = QtWidgets.QLabel() layout.addWidget(self.in_value_label) self.out_value_label = QtWidgets.QLabel() layout.addWidget(self.out_value_label) self.fees_label = QtWidgets.QLabel() layout.addWidget(self.fees_label) self.reset_labels() self.transactions_table = QtWidgets.QTableWidget() self.transactions_table.setColumnCount(5) self.transactions_table.horizontalHeader().setSectionResizeMode( QtWidgets.QHeaderView.ResizeToContents ) self.transactions_table.horizontalHeader().setStretchLastSection(True) self._horiz_header_labels = [ "Inputs", "Outputs", "Output amount", "Fee", "Output addresses", ] layout.addWidget(self.transactions_table) buttons_layout = QtWidgets.QHBoxLayout() layout.addLayout(buttons_layout) self.save_button = QtWidgets.QPushButton("Save") buttons_layout.addWidget(self.save_button) self.sign_button = QtWidgets.QPushButton("Sign") buttons_layout.addWidget(self.sign_button) self.broadcast_button = QtWidgets.QPushButton("Broadcast") buttons_layout.addWidget(self.broadcast_button) self.disable_buttons() self.save_button.clicked.connect(self.on_save_clicked) self.sign_button.clicked.connect(self.on_sign_clicked) self.broadcast_button.clicked.connect(self.on_broadcast_clicked) def reset_labels(self): self.num_tx_label.setText("Number of transactions:") self.in_value_label.setText("Total input value:") self.out_value_label.setText("Total output value:") self.fees_label.setText("Total fees:") def disable_buttons(self): self.save_button.setEnabled(False) self.sign_button.setEnabled(False) self.broadcast_button.setEnabled(False) def set_displayed_number_of_transactions(self, num_tx: int): """This method can be called to set the number of transactions without actually setting the transactions. It cen be used to demonstrate that progress is being made while transactions are still being built.""" self.num_tx_label.setText(f"Number of transactions: {num_tx}") def set_transactions(self, transactions: Sequence[transaction.Transaction]): """Enable buttons, compute and display some information about transactions.""" self.transactions_table.clear() self.transactions = transactions can_sign = self.wallet.can_sign(transactions[0]) if transactions else False # Reset buttons when fresh unsigned transactions are set self.save_button.setText("Save") self.save_button.setEnabled(True) self.sign_button.setEnabled(can_sign) self.broadcast_button.setEnabled(self.are_transactions_complete()) self.num_tx_label.setText(f"Number of transactions: {len(transactions)}") sats_per_unit = 10**XEC.decimals sum_in_value, sum_out_value, sum_fees = 0, 0, 0 self.transactions_table.setRowCount(len(transactions)) self.transactions_table.setHorizontalHeaderLabels(self._horiz_header_labels) has_missing_input_values = False for i, tx in enumerate(transactions): out_value = tx.output_value() sum_out_value += out_value try: in_value = tx.input_value() except transaction.InputValueMissing: has_missing_input_values = True fee_item = QtWidgets.QTableWidgetItem("N.A.") fee_item.setToolTip( "Raw signed transactions don't specify input amounts" ) # TODO: asynchronously fetch the input values from the network to # update the item and labels without slowing down the user else: fee = in_value - out_value sum_in_value += in_value sum_fees += fee fee_item = QtWidgets.QTableWidgetItem(f"{fee / sats_per_unit:.2f}") self.transactions_table.setItem( i, 0, QtWidgets.QTableWidgetItem(f"{len(tx.inputs())}") ) self.transactions_table.setItem( i, 1, QtWidgets.QTableWidgetItem(f"{len(tx.outputs())}") ) self.transactions_table.setItem( i, 2, QtWidgets.QTableWidgetItem(f"{out_value / sats_per_unit:.2f}") ) self.transactions_table.setItem(i, 3, fee_item) # Print the output addresses on colored background, with a color depending # on the hash of the output addresses. This helps with controlling that # all the outputs are the same, when needed. addresses_set = {addr.to_cashaddr() for (_, addr, _) in tx.outputs()} addresses_txt = ", ".join(sorted(addresses_set)) color_item = QtWidgets.QTableWidgetItem(addresses_txt) color_item.setToolTip(addresses_txt) h = sha256(addresses_txt.encode("utf8")) color_item.setBackground(QtGui.QColor(h[0], h[1], h[2])) self.transactions_table.setItem(i, 4, color_item) self.out_value_label.setText( f"Total output value: {sum_out_value / sats_per_unit} {XEC}" ) if not has_missing_input_values: self.in_value_label.setText( f"Total input value: {sum_in_value / sats_per_unit} {XEC}" ) self.fees_label.setText( f"Total fees: {sum_fees / sats_per_unit} {XEC}" ) else: self.in_value_label.setText("Total input value: N.A") self.fees_label.setText("Total fees: N.A") tooltip = "Some transactions don't specify input amounts" self.in_value_label.setToolTip(tooltip) self.fees_label.setToolTip(tooltip) def on_save_clicked(self): - dir = QtWidgets.QFileDialog.getExistingDirectory( + directory = QtWidgets.QFileDialog.getExistingDirectory( self, "Select output directory for transaction files", str(Path.home()) ) - if not dir: + if not directory: return for i, tx in enumerate(self.transactions): name = ( f"signed_{i:03d}.txn" if tx.is_complete() else f"unsigned_{i:03d}.txn" ) - path = Path(dir) / name + path = Path(directory) / name tx_dict = tx.as_dict() with open(path, "w+", encoding="utf-8") as f: f.write(json.dumps(tx_dict, indent=4) + "\n") QtWidgets.QMessageBox.information( - self, "Done saving", f"Saved {len(self.transactions)} files to {dir}" + self, "Done saving", f"Saved {len(self.transactions)} files to {directory}" ) def on_sign_clicked(self): password = None if self.wallet.has_password(): password = self.main_window.password_dialog( "Enter your password to proceed" ) if not password: return for tx in self.transactions: self.wallet.sign_transaction(tx, password, use_cache=True) QtWidgets.QMessageBox.information( self, "Done signing", f"Signed {len(self.transactions)} transactions. Remember to save them!", ) self.broadcast_button.setEnabled(self.are_transactions_complete()) self.save_button.setText("Save (signed)") def are_transactions_complete(self) -> bool: if not self.transactions: return False # FIXME: for now it is assumed that all loaded transactions have the same # status (signed or unsigned). Checking for completeness is currently # too slow to be done on many large transactions. return self.transactions[0].is_complete() def on_broadcast_clicked(self): self.main_window.push_top_level_window(self) try: for tx in self.transactions: self.main_window.broadcast_transaction(tx, None) finally: self.main_window.pop_top_level_window(self) QtWidgets.QMessageBox.information( self, "Done broadcasting", f"Broadcasted {len(self.transactions)} transactions.", ) class MultiTransactionsDialog(QtWidgets.QDialog): """This dialog is just a minimalistic wrapper for the widget. It does not implement any logic.""" def __init__(self, wallet, main_window, parent=None): super().__init__(parent) layout = QtWidgets.QVBoxLayout() self.setLayout(layout) self.widget = MultiTransactionsWidget(wallet, main_window, self) layout.addWidget(self.widget) buttons_layout = QtWidgets.QHBoxLayout() layout.addLayout(buttons_layout) close_button = QtWidgets.QPushButton("Close") buttons_layout.addWidget(close_button) close_button.clicked.connect(self.accept) diff --git a/electrum/electrumabc_gui/qt/network_dialog.py b/electrum/electrumabc_gui/qt/network_dialog.py index ebfe47697..300090473 100644 --- a/electrum/electrumabc_gui/qt/network_dialog.py +++ b/electrum/electrumabc_gui/qt/network_dialog.py @@ -1,1467 +1,1467 @@ #!/usr/bin/env python3 # # Electrum ABC - lightweight eCash client # Copyright (C) 2020 The Electrum ABC developers # Copyright (C) 2012 thomasv@gitorious # # Electron Cash - lightweight Bitcoin Cash client # Copyright (C) 2020 The Electron Cash 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. import queue import socket from functools import partial from PyQt5 import QtWidgets from PyQt5.QtCore import QObject, Qt, QThread, QTimer, pyqtSignal from PyQt5.QtGui import QIcon from electrumabc import networks from electrumabc.constants import PROJECT_NAME from electrumabc.i18n import _, pgettext from electrumabc.interface import Interface from electrumabc.network import ( Network, deserialize_server, get_eligible_servers, serialize_server, ) from electrumabc.plugins import run_hook from electrumabc.printerror import PrintError, print_error from electrumabc.tor import TorController from electrumabc.util import Weak, in_main_thread from .tor_downloader import DownloadTorDialog from .util import ( Buttons, CloseButton, HelpButton, MessageBoxMixin, PasswordLineEdit, WindowModalDialog, WWLabel, char_width_in_lineedit, rate_limited, ) from .utils import UserPortValidator protocol_names = ["TCP", "SSL"] protocol_letters = "ts" class NetworkDialog(MessageBoxMixin, QtWidgets.QDialog): network_updated_signal = pyqtSignal() def __init__(self, network: Network, config): QtWidgets.QDialog.__init__(self) self.setWindowTitle(_("Network")) self.setMinimumSize(500, 350) self.nlayout = NetworkChoiceLayout(self, network, config) vbox = QtWidgets.QVBoxLayout(self) vbox.addLayout(self.nlayout.layout()) # We don't want the close button's behavior to have the enter key close # the window because user may edit text fields, etc, so we do the below: close_but = CloseButton(self) close_but.setDefault(False) close_but.setAutoDefault(False) vbox.addLayout(Buttons(close_but)) self.network_updated_signal.connect(self.on_update) # below timer is to work around Qt on Linux display glitches when # showing this window. self.workaround_timer = QTimer() self.workaround_timer.timeout.connect(self._workaround_update) self.workaround_timer.setSingleShot(True) network.register_callback( self.on_network, ["blockchain_updated", "interfaces", "status"] ) self.refresh_timer = QTimer(self) self.refresh_timer.timeout.connect(self.network_updated_signal.emit) self.refresh_timer.setInterval(500) def jumpto(self, location: str): self.nlayout.jumpto(location) def on_network(self, event, *args): """This may run in network thread""" # print_error("[NetworkDialog] on_network:",event,*args) self.network_updated_signal.emit() # this enqueues call to on_update in GUI thread @rate_limited(0.333) def on_update(self): """This always runs in main GUI thread""" self.nlayout.update() def closeEvent(self, e): # Warn if non-SSL mode when closing dialog if ( not self.nlayout.ssl_cb.isChecked() and not self.nlayout.tor_cb.isChecked() and not self.nlayout.server_host.text().lower().endswith(".onion") and not self.nlayout.config.get("non_ssl_noprompt", False) ): ok, chk = self.question( "".join( [ _("You have selected non-SSL mode for your server settings."), " ", _("Using this mode presents a potential security risk."), "\n\n", _("Are you sure you wish to proceed?"), ] ), detail_text="".join( [ _( "All of your traffic to the blockchain servers will be sent" " unencrypted." ), " ", _( "Additionally, you may also be vulnerable to" " man-in-the-middle attacks." ), " ", _( "It is strongly recommended that you go back and enable SSL" " mode." ), ] ), rich_text=False, title=_("Security Warning"), icon=QtWidgets.QMessageBox.Critical, checkbox_text="Don't ask me again", ) if chk: self.nlayout.config.set_key("non_ssl_noprompt", True) if not ok: e.ignore() return super().closeEvent(e) def hideEvent(self, e): super().hideEvent(e) if not self.isVisible(): self.workaround_timer.stop() self.refresh_timer.stop() def showEvent(self, e): super().showEvent(e) if e.isAccepted(): # Single-shot. Works around Linux/Qt bugs # -- see _workaround_update below for description. self.workaround_timer.start(500) self.refresh_timer.start() def _workaround_update(self): # Hack to work around strange behavior on some Linux: # On some Linux systems (Debian based), the dialog sometimes is empty # and glitchy if we don't do this. Note this .update() call is a Qt # C++ QWidget::update() call and has nothing to do with our own # same-named `update` methods. QtWidgets.QDialog.update(self) class NodesListWidget(QtWidgets.QTreeWidget): def __init__(self, parent): QtWidgets.QTreeWidget.__init__(self) self.parent = parent self.setHeaderLabels([_("Connected node"), "", _("Height")]) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.create_menu) def create_menu(self, position): item = self.currentItem() if not item: return is_server = not bool(item.data(0, Qt.UserRole)) menu = QtWidgets.QMenu() if is_server: server = item.data(1, Qt.UserRole) menu.addAction( _("Use as server"), lambda: self.parent.follow_server(server) ) else: index = item.data(1, Qt.UserRole) menu.addAction( _("Follow this branch"), lambda: self.parent.follow_branch(index) ) menu.exec_(self.viewport().mapToGlobal(position)) def keyPressEvent(self, event): if event.key() in {Qt.Key_F2, Qt.Key_Return}: item, col = self.currentItem(), self.currentColumn() if item and col > -1: self.on_activated(item, col) else: super().keyPressEvent(event) def on_activated(self, item, column): # on 'enter' we show the menu pt = self.visualItemRect(item).bottomLeft() pt.setX(50) self.customContextMenuRequested.emit(pt) def update_servers(self, network, servers): item = self.currentItem() selection_data = None if item: selection_data = item.data(1, Qt.UserRole) self.clear() chains = network.get_blockchains() n_chains = len(chains) previous_server_item = None for k, items in chains.items(): b = network.blockchains[k] name = b.get_name() if n_chains > 1: # group the servers as children of a parent chain item blockchain_root_item = QtWidgets.QTreeWidgetItem( [name + f"@{b.get_base_height()}", "", f"{b.height()}"] ) blockchain_root_item.setData(0, Qt.UserRole, 1) blockchain_root_item.setData(1, Qt.UserRole, b.base_height) self.addTopLevelItem(blockchain_root_item) else: # group servers as direct children of the tree widget # (simple list) blockchain_root_item = self.invisibleRootItem() # Add servers for i in items: star = " ◀" if i == network.interface else "" display_text = i.host is_onion = i.host.lower().endswith(".onion") if is_onion and i.host in servers and "display" in servers[i.host]: display_text = servers[i.host]["display"] + " (.onion)" item = QtWidgets.QTreeWidgetItem( [display_text + star, "", "%d" % i.tip] ) item.setData(0, Qt.UserRole, 0) item.setData(1, Qt.UserRole, i.server) if i.server == selection_data: previous_server_item = item if is_onion: item.setIcon(1, QIcon(":icons/tor_logo.svg")) blockchain_root_item.addChild(item) blockchain_root_item.setExpanded(True) # restore selection, if there was any if previous_server_item: self.setCurrentItem(previous_server_item) h = self.header() h.setStretchLastSection(False) h.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) h.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) h.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) class ServerFlag: """Used by ServerListWidget for Server flags & Symbols""" BadCertificate = 4 # Servers with a bad certificate. Banned = 2 # Blacklisting/banning was a hidden mechanism inherited from Electrum. We would blacklist misbehaving servers under the hood. Now that facility is exposed (editable by the user). We never connect to blacklisted servers. Preferred = 1 # Preferred servers (white-listed) start off as the servers in servers.json and are "more trusted" and optionally the user can elect to connect to only these servers NoFlag = 0 Symbol = {NoFlag: "", Preferred: "⭐", Banned: "⛔", BadCertificate: "❗️"} UnSymbol = { # used for "disable X" context menu NoFlag: "", Preferred: "❌", Banned: "✅", BadCertificate: "", } class ServerListWidget(QtWidgets.QTreeWidget): def __init__(self, parent): QtWidgets.QTreeWidget.__init__(self) self.parent = parent self.setHeaderLabels(["", _("Host"), "", _("Port")]) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.create_menu) def create_menu(self, position): item = self.currentItem() if not item: return menu = QtWidgets.QMenu() server = item.data(2, Qt.UserRole) if self.parent.can_set_server(server): useAction = menu.addAction( _("Use as server"), lambda: self.set_server(server) ) else: useAction = menu.addAction(server.split(":", 1)[0], lambda: None) useAction.setDisabled(True) menu.addSeparator() flagval = item.data(0, Qt.UserRole) iswl = flagval & ServerFlag.Preferred if flagval & ServerFlag.Banned: optxt = ServerFlag.UnSymbol[ServerFlag.Banned] + " " + _("Unban server") isbl = True useAction.setDisabled(True) useAction.setText(_("Server banned")) else: optxt = ServerFlag.Symbol[ServerFlag.Banned] + " " + _("Ban server") isbl = False if not isbl: if flagval & ServerFlag.Preferred: optxt_fav = ( ServerFlag.UnSymbol[ServerFlag.Preferred] + " " + _("Remove from preferred") ) else: optxt_fav = ( ServerFlag.Symbol[ServerFlag.Preferred] + " " + _("Add to preferred") ) menu.addAction( optxt_fav, lambda: self.parent.set_whitelisted(server, not iswl) ) menu.addAction(optxt, lambda: self.parent.set_blacklisted(server, not isbl)) if flagval & ServerFlag.BadCertificate: optxt = ( ServerFlag.UnSymbol[ServerFlag.BadCertificate] + " " + _("Remove pinned certificate") ) menu.addAction(optxt, partial(self.on_remove_pinned_certificate, server)) menu.exec_(self.viewport().mapToGlobal(position)) def on_remove_pinned_certificate(self, server): if not self.parent.remove_pinned_certificate(server): QtWidgets.QMessageBox.critical( None, _("Remove pinned certificate"), _("Failed to remove the pinned certificate. Check the log for errors."), ) def set_server(self, s): host, port, protocol = deserialize_server(s) self.parent.server_host.setText(host) self.parent.server_port.setText(port) self.parent.autoconnect_cb.setChecked( False ) # force auto-connect off if they did "Use as server" self.parent.set_server() self.parent.update() def keyPressEvent(self, event): if event.key() in [Qt.Key_F2, Qt.Key_Return]: item, col = self.currentItem(), self.currentColumn() if item and col > -1: self.on_activated(item, col) else: super().keyPressEvent(event) def on_activated(self, item, column): # on 'enter' we show the menu pt = self.visualItemRect(item).bottomLeft() pt.setX(50) self.customContextMenuRequested.emit(pt) @staticmethod def lightenItemText(item, rang=None): if rang is None: rang = range(0, item.columnCount()) for i in rang: brush = item.foreground(i) color = brush.color() color.setHsvF(color.hueF(), color.saturationF(), 0.5) brush.setColor(color) item.setForeground(i, brush) def update(self, network, servers, protocol, use_tor): self.clear() self.setIndentation(0) wl_only = network.is_whitelist_only() for _host, d in sorted(servers.items()): is_onion = _host.lower().endswith(".onion") if is_onion and not use_tor: continue port = d.get(protocol) if port: server = serialize_server(_host, port, protocol) flag = "" flagval = 0 tt = "" if network.server_is_blacklisted(server): flagval |= ServerFlag.Banned if network.server_is_whitelisted(server): flagval |= ServerFlag.Preferred if network.server_is_bad_certificate(server): flagval |= ServerFlag.BadCertificate if flagval & ServerFlag.Banned: flag = ServerFlag.Symbol[ServerFlag.Banned] tt = _("This server is banned") elif flagval & ServerFlag.BadCertificate: flag = ServerFlag.Symbol[ServerFlag.BadCertificate] tt = _( "This server's pinned certificate mismatches its current" " certificate" ) elif flagval & ServerFlag.Preferred: flag = ServerFlag.Symbol[ServerFlag.Preferred] tt = _("This is a preferred server") display_text = _host if is_onion and "display" in d: display_text = d["display"] + " (.onion)" x = QtWidgets.QTreeWidgetItem([flag, display_text, "", port]) if is_onion: x.setIcon(2, QIcon(":icons/tor_logo.svg")) if tt: x.setToolTip(0, tt) if ( wl_only and not flagval & ServerFlag.Preferred ) or flagval & ServerFlag.Banned: # lighten the text of servers we can't/won't connect to for the given mode self.lightenItemText(x, range(1, 4)) x.setData(2, Qt.UserRole, server) x.setData(0, Qt.UserRole, flagval) x.setTextAlignment(0, Qt.AlignHCenter) self.addTopLevelItem(x) h = self.header() h.setStretchLastSection(False) h.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) h.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) h.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) h.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) class NetworkChoiceLayout(QObject, PrintError): def __init__(self, parent, network: Network, config, wizard=False): super().__init__(parent) self.network = network self.config = config self.protocol = None self.tor_proxy = None # tor detector self.td = TorDetector(self, self.network) self.td.found_proxy.connect(self.suggest_proxy) self.tabs = tabs = QtWidgets.QTabWidget() self.server_tab = server_tab = QtWidgets.QWidget() weakTd = Weak.ref(self.td) class ProxyTab(QtWidgets.QWidget): def showEvent(slf, e): super().showEvent(e) td = weakTd() if e.isAccepted() and td: td.start() # starts the tor detector when proxy_tab appears def hideEvent(slf, e): super().hideEvent(e) td = weakTd() if e.isAccepted() and td: td.stop() # stops the tor detector when proxy_tab disappears self.proxy_tab = proxy_tab = ProxyTab() self.blockchain_tab = blockchain_tab = QtWidgets.QWidget() tabs.addTab(blockchain_tab, _("Overview")) tabs.addTab(server_tab, _("Server")) tabs.addTab(proxy_tab, _("Proxy")) fixed_width_hostname = 24 * char_width_in_lineedit() fixed_width_port = 6 * char_width_in_lineedit() if wizard: tabs.setCurrentIndex(1) # server tab grid = QtWidgets.QGridLayout(server_tab) grid.setSpacing(8) self.server_host = QtWidgets.QLineEdit() self.server_host.setFixedWidth(fixed_width_hostname) self.server_port = QtWidgets.QLineEdit() self.server_port.setFixedWidth(fixed_width_port) self.ssl_cb = QtWidgets.QCheckBox(_("Use SSL")) self.autoconnect_cb = QtWidgets.QCheckBox(_("Select server automatically")) self.autoconnect_cb.setEnabled(self.config.is_modifiable("auto_connect")) weakSelf = Weak.ref( self ) # Qt/Python GC hygiene: avoid strong references to self in lambda slots. self.server_host.editingFinished.connect( lambda: weakSelf() and weakSelf().set_server(onion_hack=True) ) self.server_port.editingFinished.connect( lambda: weakSelf() and weakSelf().set_server(onion_hack=True) ) self.ssl_cb.clicked.connect(self.change_protocol) self.autoconnect_cb.clicked.connect(self.set_server) self.autoconnect_cb.clicked.connect(self.update) msg = " ".join( [ _( f"If auto-connect is enabled, {PROJECT_NAME} will always use a " "server that is on the longest blockchain." ), _( "If it is disabled, you have to choose a server you want to use." f" {PROJECT_NAME} will warn you if your server is lagging." ), ] ) grid.addWidget(self.autoconnect_cb, 0, 0, 1, 3) grid.addWidget(HelpButton(msg), 0, 4) self.preferred_only_cb = QtWidgets.QCheckBox( _("Connect only to preferred servers") ) self.preferred_only_cb.setEnabled( self.config.is_modifiable("whitelist_servers_only") ) self.preferred_only_cb.setToolTip( _( f"If enabled, restricts {PROJECT_NAME} to connecting to " "servers only marked as 'preferred'." ) ) self.preferred_only_cb.clicked.connect( self.set_whitelisted_only ) # re-set the config key and notify network.py msg = "\n\n".join( [ _( "If 'Connect only to preferred servers' is enabled, " f"{PROJECT_NAME} will only connect to servers marked as " f"'preferred' servers ({ServerFlag.Symbol[ServerFlag.Preferred]})." ), _( "This feature was added in response to the potential for a " "malicious actor to deny service via launching many servers " "(aka a sybil attack)." ), _( "If unsure, most of the time it's safe to leave this option " "disabled. However leaving it enabled is safer (if a little " "bit discouraging to new server operators wanting to populate " "their servers)." ), ] ) grid.addWidget(self.preferred_only_cb, 1, 0, 1, 3) grid.addWidget(HelpButton(msg), 1, 4) grid.addWidget(self.ssl_cb, 2, 0, 1, 3) self.ssl_help = HelpButton( _( "SSL is used to authenticate and encrypt your connections with the" " blockchain servers." ) + "\n\n" + _( "Due to potential security risks, you may only disable SSL when using a" " Tor Proxy." ) ) grid.addWidget(self.ssl_help, 2, 4) grid.addWidget(QtWidgets.QLabel(_("Server") + ":"), 3, 0) grid.addWidget(self.server_host, 3, 1, 1, 2) grid.addWidget(self.server_port, 3, 3) # will get set by self.update() self.server_list_label = label = QtWidgets.QLabel("") grid.addWidget(label, 4, 0, 1, 5) self.servers_list = ServerListWidget(self) grid.addWidget(self.servers_list, 5, 0, 1, 5) # will get populated with the legend by self.update() self.legend_label = label = WWLabel("") label.setTextInteractionFlags( label.textInteractionFlags() & (~Qt.TextSelectableByMouse) ) # disable text selection by mouse here self.legend_label.linkActivated.connect(self.on_view_blacklist) grid.addWidget(label, 6, 0, 1, 4) msg = " ".join( [ _( "Preferred servers ({}) are servers you have designated as reliable" " and/or trustworthy." ).format(ServerFlag.Symbol[ServerFlag.Preferred]), _( "Initially, the preferred list is the hard-coded list of " f"known-good servers vetted by the {PROJECT_NAME} developers." ), _( "You can add or remove any server from this list and" " optionally elect to only connect to preferred servers." ), "\n\n" + _( f"Banned servers ({ServerFlag.Symbol[ServerFlag.Banned]}) " "are servers deemed unreliable and/or untrustworthy, and " f"so they will never be connected-to by {PROJECT_NAME}" ), ] ) grid.addWidget(HelpButton(msg), 6, 4) # Proxy tab grid = QtWidgets.QGridLayout(proxy_tab) grid.setSpacing(8) # proxy setting self.proxy_cb = QtWidgets.QCheckBox(_("Use proxy")) self.proxy_cb.setToolTip( _( "If enabled, all connections application-wide will be routed through" " this proxy." ) ) self.proxy_cb.clicked.connect(self.check_disable_proxy) self.proxy_cb.clicked.connect(self.set_proxy) self.proxy_mode = QtWidgets.QComboBox() self.proxy_mode.addItems(["SOCKS4", "SOCKS5", "HTTP"]) self.proxy_host = QtWidgets.QLineEdit() self.proxy_host.setFixedWidth(fixed_width_hostname) self.proxy_port = QtWidgets.QLineEdit() self.proxy_port.setFixedWidth(fixed_width_port) self.proxy_user = QtWidgets.QLineEdit() self.proxy_user.setPlaceholderText(_("Proxy user")) self.proxy_password = PasswordLineEdit() self.proxy_password.setPlaceholderText(_("Password")) self.proxy_mode.currentIndexChanged.connect(self.set_proxy) self.proxy_host.editingFinished.connect(self.set_proxy) self.proxy_port.editingFinished.connect(self.set_proxy) self.proxy_user.editingFinished.connect(self.set_proxy) self.proxy_password.editingFinished.connect(self.set_proxy) self.tor_cb = QtWidgets.QCheckBox(_("Use Tor Proxy")) self.tor_cb.setIcon(QIcon(":icons/tor_logo.svg")) self.tor_cb.setEnabled(False) self.tor_cb.clicked.connect(self.use_tor_proxy) tor_proxy_tooltip = _( "If enabled, all connections application-wide will be routed through Tor." ) tor_proxy_help = ( tor_proxy_tooltip + "\n\n" + _( "Depending on your configuration and preferences as a user, this may or" " may not be ideal. In general, connections routed through Tor hide" " your IP address from servers, at the expense of performance and" " network throughput." ) + "\n\n" + _( "For the average user, it's recommended that you leave this option " "disabled and only leave the 'Start Tor client' option enabled." ) ) self.tor_cb.setToolTip(tor_proxy_tooltip) self.tor_enabled = QtWidgets.QCheckBox() self.tor_enabled.setIcon(QIcon(":icons/tor_logo.svg")) self.tor_enabled.clicked.connect(self.set_tor_enabled) self.tor_enabled.setChecked(self.network.tor_controller.is_enabled()) self.tor_enabled_help = HelpButton("") self.tor_custom_port_cb = QtWidgets.QCheckBox(_("Custom port")) self.tor_enabled.clicked.connect(self.tor_custom_port_cb.setEnabled) self.tor_custom_port_cb.setChecked( bool(self.network.tor_controller.get_socks_port()) ) self.tor_custom_port_cb.clicked.connect(self.on_custom_port_cb_click) custom_port_tooltip = _("Leave unspecified to automatically allocate a port.") self.tor_custom_port_cb.setToolTip(custom_port_tooltip) self.network.tor_controller.status_changed.append_weak( self.on_tor_status_changed ) self.tor_socks_port = QtWidgets.QLineEdit() self.tor_socks_port.setFixedWidth(fixed_width_port) self.tor_socks_port.editingFinished.connect(self.set_tor_socks_port) self.tor_socks_port.setText(str(self.network.tor_controller.get_socks_port())) self.tor_socks_port.setToolTip(custom_port_tooltip) self.tor_socks_port.setValidator( UserPortValidator(self.tor_socks_port, accept_zero=True) ) self.dl_tor_button = QtWidgets.QPushButton(_("Download Tor")) self.dl_tor_button.clicked.connect(self._show_download_tor_dialog) self.dl_tor_help = HelpButton( _("Download a compiled Tor executable.") + "\n\n" + _( f"A Tor executable is provided by {PROJECT_NAME} for convenience, if " "you don't know how to install Tor on your operating system. Linux " "users are advised to install Tor via their package manager instead." ) ) self.update_tor_enabled() # Start Tor grid.addWidget(self.dl_tor_button, 0, 1, 1, 2) grid.addWidget(self.dl_tor_help, 0, 4) grid.addWidget(self.tor_enabled, 1, 0, 1, 2) grid.addWidget(self.tor_enabled_help, 1, 4) # Custom Tor port hbox = QtWidgets.QHBoxLayout() hbox.addSpacing(20) # indentation hbox.addWidget(self.tor_custom_port_cb, 0, Qt.AlignLeft | Qt.AlignVCenter) hbox.addWidget(self.tor_socks_port, 0, Qt.AlignLeft | Qt.AlignVCenter) hbox.addStretch(2) hbox.setContentsMargins(0, 0, 0, 6) # a bit of a "paragraph break" here grid.addLayout(hbox, 2, 0, 1, 3) grid.addWidget(HelpButton(custom_port_tooltip), 2, 4) # Use Tor Proxy grid.addWidget(self.tor_cb, 3, 0, 1, 3) grid.addWidget(HelpButton(tor_proxy_help), 3, 4) # Proxy settings grid.addWidget(self.proxy_cb, 4, 0, 1, 3) grid.addWidget( HelpButton( _( f"Proxy settings apply to all connections: with {PROJECT_NAME}" " servers, but also with third-party services." ) ), 4, 4, ) grid.addWidget(self.proxy_mode, 6, 1) grid.addWidget(self.proxy_host, 6, 2) grid.addWidget(self.proxy_port, 6, 3) sublayout = QtWidgets.QHBoxLayout() sublayout.addWidget(self.proxy_user) sublayout.addWidget(self.proxy_password) grid.addLayout(sublayout, 7, 2, 1, 2) grid.setRowStretch(8, 1) # Blockchain Tab grid = QtWidgets.QGridLayout(blockchain_tab) msg = " ".join( [ _( f"{PROJECT_NAME} connects to several nodes in order to " "download block headers and find out the longest blockchain." ), _( "This blockchain is used to verify the transactions sent by " "your transaction server." ), ] ) row = 0 self.status_label = QtWidgets.QLabel("") self.status_label.setTextInteractionFlags( self.status_label.textInteractionFlags() | Qt.TextSelectableByMouse ) grid.addWidget(QtWidgets.QLabel(_("Status") + ":"), row, 0) grid.addWidget(self.status_label, row, 1, 1, 3) grid.addWidget(HelpButton(msg), row, 4) row += 1 self.server_label = QtWidgets.QLabel("") self.server_label.setTextInteractionFlags( self.server_label.textInteractionFlags() | Qt.TextSelectableByMouse ) msg = _( f"{PROJECT_NAME} sends your wallet addresses to a single " "server, in order to receive your transaction history." ) grid.addWidget(QtWidgets.QLabel(_("Server") + ":"), row, 0) grid.addWidget(self.server_label, row, 1, 1, 3) grid.addWidget(HelpButton(msg), row, 4) row += 1 self.height_label = QtWidgets.QLabel("") self.height_label.setTextInteractionFlags( self.height_label.textInteractionFlags() | Qt.TextSelectableByMouse ) msg = _("This is the height of your local copy of the blockchain.") grid.addWidget(QtWidgets.QLabel(_("Blockchain") + ":"), row, 0) grid.addWidget(self.height_label, row, 1) grid.addWidget(HelpButton(msg), row, 4) row += 1 self.reqs_label = QtWidgets.QLabel("") self.reqs_label.setTextInteractionFlags( self.height_label.textInteractionFlags() | Qt.TextSelectableByMouse ) msg = _( "The number of unanswered network requests.\n\n" "You can configure:\n\n" " - Limit: maximum request backlog size\n" " - ChunkSize: requests to enqueue every 100ms\n\n" "If the connection drops when synchronizing, you may wish " "to reduce these values to throttle requests to the server." ) grid.addWidget(QtWidgets.QLabel(_("Pending requests") + ":"), row, 0) hbox = QtWidgets.QHBoxLayout() hbox.addWidget(self.reqs_label) hbox.setContentsMargins(0, 0, 12, 0) hbox.addWidget(QtWidgets.QLabel(_("Limit:"))) self.req_max_sb = sb = QtWidgets.QSpinBox() sb.setRange(1, 2000) sb.setFocusPolicy(Qt.TabFocus | Qt.ClickFocus | Qt.WheelFocus) hbox.addWidget(sb) hbox.addWidget(QtWidgets.QLabel(_("ChunkSize:"))) self.req_chunk_sb = sb = QtWidgets.QSpinBox() sb.setRange(1, 100) sb.setFocusPolicy(Qt.TabFocus | Qt.ClickFocus | Qt.WheelFocus) hbox.addWidget(sb) but = QtWidgets.QPushButton(_("Reset")) f = but.font() f.setPointSize(f.pointSize() - 2) but.setFont(f) but.setDefault(False) but.setAutoDefault(False) hbox.addWidget(but) grid.addLayout(hbox, row, 1, 1, 3) grid.setAlignment(hbox, Qt.AlignLeft | Qt.AlignVCenter) grid.setColumnStretch(3, 1) grid.addWidget(HelpButton(msg), row, 4) row += 1 def req_max_changed(val): - Interface.set_req_throttle_params(self.config, max=val) + Interface.set_req_throttle_params(self.config, max_unanswered_requests=val) def req_chunk_changed(val): Interface.set_req_throttle_params(self.config, chunkSize=val) def req_defaults(): p = Interface.req_throttle_default Interface.set_req_throttle_params( - self.config, max=p.max, chunkSize=p.chunkSize + self.config, max_unanswered_requests=p.max, chunkSize=p.chunkSize ) self.update() but.clicked.connect(req_defaults) self.req_max_sb.valueChanged.connect(req_max_changed) self.req_chunk_sb.valueChanged.connect(req_chunk_changed) self.split_label = QtWidgets.QLabel("") self.split_label.setTextInteractionFlags( self.split_label.textInteractionFlags() | Qt.TextSelectableByMouse ) grid.addWidget(self.split_label, row, 0, 1, 3) row += 2 self.nodes_list_widget = NodesListWidget(self) grid.addWidget(self.nodes_list_widget, row, 0, 1, 5) row += 1 vbox = QtWidgets.QVBoxLayout() vbox.addWidget(tabs) self.layout_ = vbox self.network.tor_controller.active_port_changed.append_weak( self.on_tor_port_changed ) self.network.server_list_updated.append_weak(self.on_server_list_updated) self.fill_in_proxy_settings() self.update() _tor_client_names = { TorController.BinaryType.MISSING: _("Tor"), TorController.BinaryType.SYSTEM: _("system Tor"), TorController.BinaryType.DOWNLOADED: _("downloaded Tor"), } def update_tor_enabled(self, *args): tbt = self.network.tor_controller.tor_binary_type tbname = self._tor_client_names[tbt] available = tbt != TorController.BinaryType.MISSING self.dl_tor_button.setVisible(not available) self.dl_tor_help.setVisible(not available) self.tor_enabled.setText( _("Start {tor_binary_name} client").format( tor_binary_name=tbname, tor_binary_name_capitalized=tbname[:1].upper() + tbname[1:], ) ) self.tor_enabled.setEnabled(available) self.tor_custom_port_cb.setEnabled(available and self.tor_enabled.isChecked()) self.tor_socks_port.setEnabled( available and self.tor_custom_port_cb.isChecked() ) tor_enabled_tooltip = [ _( "This will start a private instance of the Tor proxy " f"controlled by {PROJECT_NAME}." ) ] if not available: tor_enabled_tooltip.insert( 0, _("This feature is unavailable because no Tor binary was found.") ) tor_enabled_tooltip_text = " ".join(tor_enabled_tooltip) self.tor_enabled.setToolTip(tor_enabled_tooltip_text) self.tor_enabled_help.help_text = ( tor_enabled_tooltip_text + "\n\n" + _( "If unsure, it's safe to enable this feature, and leave 'Use Tor Proxy'" " disabled. In that situation, only certain plugins (such as" " CashFusion) will use Tor, but your regular SPV server connections" " will remain unaffected." ) ) def jumpto(self, location: str): if not isinstance(location, str): return location = location.strip().lower() if location in ("proxy", "tor"): self.tabs.setCurrentWidget(self.proxy_tab) elif location in ("servers", "server"): self.tabs.setCurrentWidget(self.server_tab) elif location in ("blockchain", "overview", "main"): self.tabs.setCurrentWidget(self.blockchain_tab) elif not run_hook("on_network_dialog_jumpto", self, location): self.print_error(f"jumpto: unknown location '{location}'") @in_main_thread def on_tor_port_changed(self, controller: TorController): if ( not controller.active_socks_port or not controller.is_enabled() or not self.tor_use ): return # The Network class handles actually changing the port, we just # set the value in the text box here. self.proxy_port.setText(str(controller.active_socks_port)) @in_main_thread def on_server_list_updated(self): self.update() def check_disable_proxy(self, b): if not self.config.is_modifiable("proxy"): b = False if self.tor_use: # Disallow changing the proxy settings when Tor is in use b = False for w in [ self.proxy_mode, self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password, ]: w.setEnabled(b) def get_set_server_flags(self): return ( self.config.is_modifiable("server"), ( not self.autoconnect_cb.isChecked() and not self.preferred_only_cb.isChecked() ), ) def can_set_server(self, server): return bool( self.get_set_server_flags()[0] and not self.network.server_is_blacklisted(server) and ( not self.network.is_whitelist_only() or self.network.server_is_whitelisted(server) ) ) def enable_set_server(self): modifiable, notauto = self.get_set_server_flags() if modifiable: self.server_host.setEnabled(notauto) self.server_port.setEnabled(notauto) else: for w in [self.autoconnect_cb, self.server_host, self.server_port]: w.setEnabled(False) def update(self): host, port, protocol, proxy_config, auto_connect = self.network.get_parameters() preferred_only = self.network.is_whitelist_only() if not self.server_host.hasFocus() and not self.server_port.hasFocus(): self.server_host.setText(host) self.server_port.setText(port) self.ssl_cb.setChecked(protocol == "s") ssl_disable = ( self.ssl_cb.isChecked() and not self.tor_cb.isChecked() and not host.lower().endswith(".onion") ) for w in [self.ssl_cb]: # , self.ssl_help]: w.setDisabled(ssl_disable) self.autoconnect_cb.setChecked(auto_connect) self.preferred_only_cb.setChecked(preferred_only) self.servers = self.network.get_servers() host = ( self.network.interface.host if self.network.interface else pgettext("Referencing server", "None") ) is_onion = host.lower().endswith(".onion") if is_onion and host in self.servers and "display" in self.servers[host]: host = self.servers[host]["display"] + " (.onion)" self.server_label.setText(host) self.set_protocol(protocol) def protocol_suffix(): if protocol == "t": return " (non-SSL)" elif protocol == "s": return " [SSL]" return "" server_list_txt = ( _("Server peers") if self.network.is_connected() else _("Servers") ) + " ({})".format(len(self.servers)) server_list_txt += protocol_suffix() self.server_list_label.setText(server_list_txt) if self.network.blacklisted_servers: bl_srv_ct_str = ' ({}) {}'.format( len(self.network.blacklisted_servers), _("View ban list...") ) else: bl_srv_ct_str = " (0) " # ensure rich text servers_whitelisted = ( set(get_eligible_servers(self.servers, protocol)).intersection( self.network.whitelisted_servers ) - self.network.blacklisted_servers ) self.legend_label.setText( ServerFlag.Symbol[ServerFlag.Preferred] + "=" + _("Preferred") + " ({})".format(len(servers_whitelisted)) + "     " + ServerFlag.Symbol[ServerFlag.Banned] + "=" + _("Banned") + bl_srv_ct_str ) self.servers_list.update( self.network, self.servers, self.protocol, self.tor_cb.isChecked() ) self.enable_set_server() height_str = "%d " % (self.network.get_local_height()) + _("blocks") self.height_label.setText(height_str) n = len(self.network.get_interfaces()) status = _("Connected to %d nodes.") % n if n else _("Not connected") if n: status += protocol_suffix() self.status_label.setText(status) chains = self.network.get_blockchains() if len(chains) > 1: chain = self.network.blockchain() checkpoint = chain.get_base_height() name = chain.get_name() msg = _("Chain split detected at block %d") % checkpoint + "\n" msg += ( ( _("You are following branch") if auto_connect else _("Your server is on branch") ) + " " + name ) msg += " (%d %s)" % (chain.get_branch_size(), _("blocks")) else: msg = "" self.split_label.setText(msg) self.reqs_label.setText( str( ( self.network.interface and len(self.network.interface.unanswered_requests) ) or 0 ) ) params = Interface.get_req_throttle_params(self.config) self.req_max_sb.setValue(params.max) self.req_chunk_sb.setValue(params.chunkSize) self.nodes_list_widget.update_servers(self.network, self.servers) def fill_in_proxy_settings(self): host, port, protocol, proxy_config, auto_connect = self.network.get_parameters() if not proxy_config: proxy_config = {"mode": "none", "host": "localhost", "port": "9050"} # We need to restore the "Use tor" checkbox as its value is needed in the server # list, to determine whether to show .onion servers, before the TorDetector # has been started. self._set_tor_use(self.config.get("tor_use", False)) b = proxy_config.get("mode") != "none" self.check_disable_proxy(b) if b: self.proxy_cb.setChecked(True) self.proxy_mode.setCurrentIndex( self.proxy_mode.findText(str(proxy_config.get("mode").upper())) ) self.proxy_host.setText(proxy_config.get("host")) self.proxy_port.setText(proxy_config.get("port")) self.proxy_user.setText(proxy_config.get("user", "")) self.proxy_password.setText(proxy_config.get("password", "")) def layout(self): return self.layout_ def set_protocol(self, protocol): if protocol != self.protocol: self.protocol = protocol def change_protocol(self, use_ssl): p = "s" if use_ssl else "t" host = self.server_host.text() pp = self.servers.get(host, networks.net.DEFAULT_PORTS) if p not in pp.keys(): p = list(pp.keys())[0] port = pp[p] self.server_host.setText(host) self.server_port.setText(port) self.set_protocol(p) self.set_server() def follow_branch(self, index): self.network.follow_chain(index) self.update() def follow_server(self, server): self.network.switch_to_interface(server) host, port, protocol, proxy, auto_connect = self.network.get_parameters() host, port, protocol = deserialize_server(server) self.network.set_parameters(host, port, protocol, proxy, auto_connect) self.update() def server_changed(self, x): if x: self.change_server(str(x.text(0)), self.protocol) def change_server(self, host, protocol): pp = self.servers.get(host, networks.net.DEFAULT_PORTS) if protocol and protocol not in protocol_letters: protocol = None if protocol: port = pp.get(protocol) if port is None: protocol = None if not protocol: if "s" in pp.keys(): protocol = "s" port = pp.get(protocol) else: protocol = list(pp.keys())[0] port = pp.get(protocol) self.server_host.setText(host) self.server_port.setText(port) self.ssl_cb.setChecked(protocol == "s") def accept(self): pass def set_server(self, onion_hack=False): host, port, protocol, proxy, auto_connect = self.network.get_parameters() host = str(self.server_host.text()).strip() port = str(self.server_port.text()).strip() protocol = "s" if self.ssl_cb.isChecked() else "t" if onion_hack: # Fix #1174 -- bring back from the dead non-SSL support for .onion only in a safe way if host.lower().endswith(".onion"): self.print_error( "Onion/TCP hack: detected .onion, forcing TCP (non-SSL) mode" ) protocol = "t" self.ssl_cb.setChecked(False) auto_connect = self.autoconnect_cb.isChecked() self.network.set_parameters(host, port, protocol, proxy, auto_connect) def set_proxy(self): host, port, protocol, proxy, auto_connect = self.network.get_parameters() if self.proxy_cb.isChecked(): proxy = { "mode": str(self.proxy_mode.currentText()).lower(), "host": str(self.proxy_host.text()), "port": str(self.proxy_port.text()), "user": str(self.proxy_user.text()), "password": str(self.proxy_password.text()), } else: proxy = None self.network.set_parameters(host, port, protocol, proxy, auto_connect) def suggest_proxy(self, found_proxy): if not found_proxy: self.tor_cb.setEnabled(False) # It's not clear to me that if the tor service goes away and comes back # later, and in the meantime they unchecked proxy_cb, that this should # remain checked. I can see it being confusing for that to be the case. # Better to uncheck. It gets auto-re-checked anyway if it comes back and # it's the same due to code below. -Calin self._set_tor_use(False) return self.tor_proxy = found_proxy self.tor_cb.setText( _("Use Tor proxy at port {tor_port}").format(tor_port=found_proxy[1]) ) same_proxy = ( self.proxy_mode.currentIndex() == self.proxy_mode.findText("SOCKS5") and self.proxy_host.text() == found_proxy[0] and self.proxy_port.text() == str(found_proxy[1]) and self.proxy_cb.isChecked() ) self._set_tor_use(same_proxy) self.tor_cb.setEnabled(True) def _set_tor_use(self, use_it): self.tor_use = use_it self.config.set_key("tor_use", self.tor_use) self.tor_cb.setChecked(self.tor_use) self.proxy_cb.setEnabled(not self.tor_use) self.check_disable_proxy(not self.tor_use) def use_tor_proxy(self, use_it): self._set_tor_use(use_it) if not use_it: self.proxy_cb.setChecked(False) else: socks5_mode_index = self.proxy_mode.findText("SOCKS5") if socks5_mode_index == -1: print_error("[network_dialog] can't find proxy_mode 'SOCKS5'") return self.proxy_mode.setCurrentIndex(socks5_mode_index) self.proxy_host.setText("127.0.0.1") self.proxy_port.setText(str(self.tor_proxy[1])) self.proxy_user.setText("") self.proxy_password.setText("") self.proxy_cb.setChecked(True) self.set_proxy() def set_tor_enabled(self, enabled: bool): self.network.tor_controller.set_enabled(enabled) @in_main_thread def on_tor_status_changed(self, controller): if controller.status == TorController.Status.ERRORED and self.tabs.isVisible(): tbname = self._tor_client_names[self.network.tor_controller.tor_binary_type] msg = _( "The {tor_binary_name} client experienced an error or could not be" " started." ).format(tor_binary_name=tbname) QtWidgets.QMessageBox.critical(None, _("Tor Client Error"), msg) def set_tor_socks_port(self): socks_port = int(self.tor_socks_port.text()) self.network.tor_controller.set_socks_port(socks_port) def on_custom_port_cb_click(self, b): self.tor_socks_port.setEnabled(b) if not b: self.tor_socks_port.setText("0") self.set_tor_socks_port() def proxy_settings_changed(self): self.tor_cb.setChecked(False) def remove_pinned_certificate(self, server): return self.network.remove_pinned_certificate(server) def set_blacklisted(self, server, bl): self.network.server_set_blacklisted(server, bl, True) # if the blacklisted server is the active server, this will force a reconnect # to another server self.set_server() self.update() def set_whitelisted(self, server, flag): self.network.server_set_whitelisted(server, flag, True) self.set_server() self.update() def set_whitelisted_only(self, b): self.network.set_whitelist_only(b) # forces us to send a set-server to network.py which recomputes eligible # servers, etc self.set_server() self.update() def on_view_blacklist(self, ignored): """The 'view ban list...' link leads to a modal dialog box where the user has the option to clear the entire blacklist. Build that dialog here.""" bl = sorted(self.network.blacklisted_servers) parent = self.parent() if not bl: parent.show_error(_("Server ban list is empty!")) return d = WindowModalDialog(parent.top_level_window(), _("Banned Servers")) vbox = QtWidgets.QVBoxLayout(d) vbox.addWidget(QtWidgets.QLabel(_("Banned Servers") + " ({})".format(len(bl)))) tree = QtWidgets.QTreeWidget() tree.setHeaderLabels([_("Host"), _("Port")]) for s in bl: host, port, protocol = deserialize_server(s) item = QtWidgets.QTreeWidgetItem([host, str(port)]) item.setFlags(Qt.ItemIsEnabled) tree.addTopLevelItem(item) tree.setIndentation(3) h = tree.header() h.setStretchLastSection(False) h.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) h.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) vbox.addWidget(tree) clear_but = QtWidgets.QPushButton(_("Clear ban list")) weakSelf = Weak.ref(self) weakD = Weak.ref(d) clear_but.clicked.connect( lambda: weakSelf() and weakSelf().on_clear_blacklist() and weakD().reject() ) vbox.addLayout(Buttons(clear_but, CloseButton(d))) d.exec_() def on_clear_blacklist(self): bl = list(self.network.blacklisted_servers) blen = len(bl) if self.parent().question( _("Clear all {} servers from the ban list?").format(blen) ): for i, s in enumerate(bl): self.network.server_set_blacklisted( s, False, save=bool(i + 1 == blen) ) # save on last iter self.update() return True return False def _show_download_tor_dialog(self): dialog = DownloadTorDialog(self.config, self.parent()) dialog.exec_() # Let TorController know about the new binary if dialog.was_download_successful: self.network.tor_controller.detect_tor() class TorDetector(QThread): found_proxy = pyqtSignal(object) def __init__(self, parent, network: Network): super().__init__(parent) self.network = network self.network.tor_controller.active_port_changed.append_weak( self.on_tor_port_changed ) def on_tor_port_changed(self, controller: TorController): if controller.active_socks_port and self.isRunning(): self.stopQ.put("kick") def start(self): # create a new stopQ blowing away the old one just in case it has old data in # it (this prevents races with stop/start arriving too quickly for the thread) self.stopQ = queue.Queue() super().start() def stop(self): if self.isRunning(): self.stopQ.put(None) self.wait() def run(self): while True: ports = [9050, 9150] # Probable ports for Tor to listen at if ( self.network.tor_controller and self.network.tor_controller.is_enabled() and self.network.tor_controller.active_socks_port ): ports.insert(0, self.network.tor_controller.active_socks_port) for p in ports: if TorDetector.is_tor_port(p): self.found_proxy.emit(("127.0.0.1", p)) break else: self.found_proxy.emit( None ) # no proxy found, will hide the Tor checkbox try: stopq = self.stopQ.get(timeout=10.0) # keep trying every 10 seconds if stopq is None: return # we must have gotten a stop signal if we get here, break out of function, ending thread # We were kicked, which means the tor port changed. # Run the detection after a slight delay which increases the reliability. QThread.msleep(250) continue except queue.Empty: continue # timeout, keep looping @staticmethod def is_tor_port(port): try: s = ( socket._socketobject if hasattr(socket, "_socketobject") else socket.socket )(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(0.1) s.connect(("127.0.0.1", port)) # Tor responds uniquely to HTTP-like requests s.send(b"GET\n") if b"Tor is not an HTTP Proxy" in s.recv(1024): return True except socket.error: pass return False