diff --git a/jmclient/jmclient/cryptoengine.py b/jmclient/jmclient/cryptoengine.py new file mode 100644 index 0000000..88de28e --- /dev/null +++ b/jmclient/jmclient/cryptoengine.py @@ -0,0 +1,266 @@ +from __future__ import print_function, absolute_import, division, unicode_literals + + +from binascii import hexlify, unhexlify +from collections import OrderedDict + + +from . import btc +from .configure import get_network + + +TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH = range(3) +NET_MAINNET, NET_TESTNET = range(2) +NET_MAP = {'mainnet': NET_MAINNET, 'testnet': NET_TESTNET} +WIF_PREFIX_MAP = {'mainnet': 0x80, 'testnet': 0xef} +BIP44_COIN_MAP = {'mainnet': 2**31, 'testnet': 2**31 + 1} + + +# +# library stuff that should be in btc/jmbitcoin +# + + +P2PKH_PRE, P2PKH_POST = b'\x76\xa9\x14', b'\x88\xac' +P2SH_P2WPKH_PRE, P2SH_P2WPKH_POST = b'\xa9\x14', b'\x87' +P2WPKH_PRE = b'\x00\x14' + + +def _pubkey_to_script(pubkey, script_pre, script_post=b''): + # sanity check for public key + # see https://github.com/bitcoin/bitcoin/blob/master/src/pubkey.h + if not ((len(pubkey) == 33 and pubkey[0] in (b'\x02', b'\x03')) or + (len(pubkey) == 65 and pubkey[0] in (b'\x04', b'\x06', b'\x07'))): + raise Exception("Invalid public key!") + h = btc.bin_hash160(pubkey) + assert len(h) == 0x14 + assert script_pre[-1] == b'\x14' + return script_pre + h + script_post + + +def pubkey_to_p2pkh_script(pubkey): + return _pubkey_to_script(pubkey, P2PKH_PRE, P2PKH_POST) + + +def pubkey_to_p2sh_p2wpkh_script(pubkey): + wscript = pubkey_to_p2wpkh_script(pubkey) + return P2SH_P2WPKH_PRE + btc.bin_hash160(wscript) + P2SH_P2WPKH_POST + + +def pubkey_to_p2wpkh_script(pubkey): + return _pubkey_to_script(pubkey, P2WPKH_PRE) + + +class classproperty(object): + """ + from https://stackoverflow.com/a/5192374 + """ + def __init__(self, f): + self.f = f + + def __get__(self, obj, owner): + return self.f(owner) + + +class SimpleLruCache(OrderedDict): + """ + note: python3.2 has a lru cache in functools + """ + def __init__(self, max_size): + OrderedDict.__init__(self) + assert max_size > 0 + self.max_size = max_size + + def __setitem__(self, key, value): + OrderedDict.__setitem__(self, key, value) + self._adjust_size() + + def _adjust_size(self): + while len(self) > self.max_size: + self.popitem(last=False) + + +# +# library stuff end +# + + +class EngineError(Exception): + pass + + +class BTCEngine(object): + # must be set by subclasses + VBYTE = None + __LRU_KEY_CACHE = SimpleLruCache(50) + + @classproperty + def BIP32_priv_vbytes(cls): + return btc.PRIVATE[NET_MAP[get_network()]] + + @classproperty + def WIF_PREFIX(cls): + return WIF_PREFIX_MAP[get_network()] + + @classproperty + def BIP44_COIN_TYPE(cls): + return BIP44_COIN_MAP[get_network()] + + @staticmethod + def privkey_to_pubkey(privkey): + return btc.privkey_to_pubkey(privkey, False) + + @staticmethod + def address_to_script(addr): + return unhexlify(btc.address_to_script(addr)) + + @classmethod + def wif_to_privkey(cls, wif): + raw = btc.b58check_to_bin(wif) + vbyte = btc.get_version_byte(wif) + + if (btc.BTC_P2PK_VBYTE[get_network()] + cls.WIF_PREFIX) & 0xff == vbyte: + key_type = TYPE_P2PKH + elif (btc.BTC_P2SH_VBYTE[get_network()] + cls.WIF_PREFIX) & 0xff == vbyte: + key_type = TYPE_P2SH_P2WPKH + else: + key_type = None + + return raw, key_type + + @classmethod + def privkey_to_wif(cls, priv): + return btc.bin_to_b58check(priv, cls.WIF_PREFIX) + + @classmethod + def derive_bip32_master_key(cls, seed): + # FIXME: slight encoding mess + return btc.bip32_deserialize( + btc.bip32_master_key(seed, vbytes=cls.BIP32_priv_vbytes)) + + @classmethod + def derive_bip32_privkey(cls, master_key, path): + assert len(path) > 1 + return cls._walk_bip32_path(master_key, path)[-1] + + @classmethod + def derive_bip32_pub_export(cls, master_key, path): + priv = cls._walk_bip32_path(master_key, path) + return btc.bip32_serialize(btc.raw_bip32_privtopub(priv)) + + @classmethod + def derive_bip32_priv_export(cls, master_key, path): + return btc.bip32_serialize(cls._walk_bip32_path(master_key, path)) + + @classmethod + def _walk_bip32_path(cls, master_key, path): + key = master_key + for lvl in path[1:]: + assert lvl >= 0 + assert lvl < 2**32 + if (key, lvl) in cls.__LRU_KEY_CACHE: + key = cls.__LRU_KEY_CACHE[(key, lvl)] + else: + cls.__LRU_KEY_CACHE[(key, lvl)] = btc.raw_bip32_ckd(key, lvl) + key = cls.__LRU_KEY_CACHE[(key, lvl)] + return key + + @classmethod + def privkey_to_script(cls, privkey): + pub = cls.privkey_to_pubkey(privkey) + return cls.pubkey_to_script(pub) + + @classmethod + def pubkey_to_script(cls, pubkey): + raise NotImplementedError() + + @classmethod + def privkey_to_address(cls, privkey): + script = cls.privkey_to_script(privkey) + return btc.script_to_address(script, cls.VBYTE) + + @classmethod + def pubkey_to_address(cls, pubkey): + script = cls.pubkey_to_script(pubkey) + return btc.script_to_address(script, cls.VBYTE) + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount): + raise NotImplementedError() + + @staticmethod + def sign_message(privkey, message): + """ + args: + privkey: bytes + message: bytes + returns: + base64-encoded signature + """ + return btc.ecdsa_sign(message, privkey, True, False) + + @classmethod + def script_to_address(cls, script): + return btc.script_to_address(script, vbyte=cls.VBYTE) + + +class BTC_P2PKH(BTCEngine): + @classproperty + def VBYTE(cls): + return btc.BTC_P2PK_VBYTE[get_network()] + + @classmethod + def pubkey_to_script(cls, pubkey): + return pubkey_to_p2pkh_script(pubkey) + + @classmethod + def sign_transaction(cls, tx, index, privkey, *args, **kwargs): + hashcode = kwargs.get('hashcode') or btc.SIGHASH_ALL + + pubkey = cls.privkey_to_pubkey(privkey) + script = cls.pubkey_to_script(pubkey) + + signing_tx = btc.serialize(btc.signature_form(tx, index, script, + hashcode=hashcode)) + # FIXME: encoding mess + sig = unhexlify(btc.ecdsa_tx_sign(signing_tx, hexlify(privkey), + **kwargs)) + + tx['ins'][index]['script'] = btc.serialize_script([sig, pubkey]) + + return tx + + +class BTC_P2SH_P2WPKH(BTCEngine): + # FIXME: implement different bip32 key export prefixes like electrum? + # see http://docs.electrum.org/en/latest/seedphrase.html#list-of-reserved-numbers + + @classproperty + def VBYTE(cls): + return btc.BTC_P2SH_VBYTE[get_network()] + + @classmethod + def pubkey_to_script(cls, pubkey): + return pubkey_to_p2sh_p2wpkh_script(pubkey) + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + assert amount is not None + + pubkey = cls.privkey_to_pubkey(privkey) + wpkscript = pubkey_to_p2wpkh_script(pubkey) + pkscript = pubkey_to_p2pkh_script(pubkey) + + signing_tx = btc.segwit_signature_form(tx, index, pkscript, amount, + hashcode=hashcode, + decoder_func=lambda x: x) + # FIXME: encoding mess + sig = unhexlify(btc.ecdsa_tx_sign(signing_tx, hexlify(privkey), + hashcode=hashcode, **kwargs)) + + assert len(wpkscript) == 0x16 + tx['ins'][index]['script'] = b'\x16' + wpkscript + tx['ins'][index]['txinwitness'] = [sig, pubkey] + + return tx diff --git a/jmclient/jmclient/storage.py b/jmclient/jmclient/storage.py new file mode 100644 index 0000000..573c572 --- /dev/null +++ b/jmclient/jmclient/storage.py @@ -0,0 +1,327 @@ +from __future__ import print_function, absolute_import, division, unicode_literals + +import os +import shutil +import atexit +import bencoder +import pyaes +from hashlib import sha256 +from argon2 import low_level +from .support import get_random_bytes + + +class Argon2Hash(object): + def __init__(self, password, salt=None, hash_len=32, salt_len=16, + time_cost=500, memory_cost=1000, parallelism=4, + argon2_type=low_level.Type.I, version=19): + """ + args: + password: password as bytes + salt: salt in bytes or None to create random one, must have length >= 8 + hash_len: generated hash length in bytes + salt_len: salt length in bytes, ignored if salt is not None, must be >= 8 + + Other arguments are argon2 settings. Only change those if you know what + you're doing. Optimized for slow hashing suitable for file encryption. + """ + # Default and recommended settings from argon2.PasswordHasher are for + # interactive logins. For encryption we want something much slower. + self.settings = { + 'time_cost': time_cost, + 'memory_cost': memory_cost, + 'parallelism': parallelism, + 'hash_len': hash_len, + 'type': argon2_type, + 'version': version + } + self.salt = salt if salt is not None else get_random_bytes(salt_len) + self.hash = low_level.hash_secret_raw(password, self.salt, + **self.settings) + + +class StorageError(Exception): + pass + + +class StoragePasswordError(StorageError): + pass + + +class Storage(object): + """ + Responsible for reading/writing [encrypted] data to disk. + + All data to be stored must be added to self.data which defaults to an + empty dict. + + self.data must contain serializable data (dict, list, tuple, bytes, numbers) + Having str objects anywhere in self.data will lead to undefined behaviour (py3). + All dict keys must be bytes. + + KDF: argon2, ENC: AES-256-CBC + """ + MAGIC_UNENC = b'JMWALLET' + MAGIC_ENC = b'JMENCWLT' + MAGIC_DETECT_ENC = b'JMWALLET' + + ENC_KEY_BYTES = 32 # AES-256 + SALT_LENGTH = 16 + + def __init__(self, path, password=None, create=False, read_only=False): + """ + args: + path: file path to storage + password: bytes or None for unencrypted file + create: create file if it does not exist + read_only: do not change anything on the file system + """ + self.path = path + self._lock_file = None + self._hash = None + self._data_checksum = None + self.data = None + self.changed = False + self.read_only = read_only + self.newly_created = False + + if not os.path.isfile(path): + if create and not read_only: + self._create_new(password) + self._save_file() + self.newly_created = True + else: + raise StorageError("File not found.") + else: + self._load_file(password) + + assert self.data is not None + assert self._data_checksum is not None + + self._create_lock() + + def is_encrypted(self): + return self._hash is not None + + def is_locked(self): + return self._lock_file and os.path.exists(self._lock_file) + + def was_changed(self): + """ + return True if data differs from data on disk + """ + return self._data_checksum != self._get_data_checksum() + + def change_password(self, password): + if self.read_only: + raise StorageError("Cannot change password of read-only file.") + self._set_hash(password) + self._save_file() + + def save(self): + """ + Write file to disk if data was modified + """ + #if not self.was_changed(): + # return + if self.read_only: + raise StorageError("Read-only storage cannot be saved.") + self._save_file() + + @classmethod + def is_storage_file(cls, path): + return cls._get_file_magic(path) in (cls.MAGIC_ENC, cls.MAGIC_UNENC) + + @classmethod + def is_encrypted_storage_file(cls, path): + return cls._get_file_magic(path) == cls.MAGIC_ENC + + @classmethod + def _get_file_magic(cls, path): + assert len(cls.MAGIC_ENC) == len(cls.MAGIC_UNENC) + with open(path, 'rb') as fh: + return fh.read(len(cls.MAGIC_ENC)) + + def _get_data_checksum(self): + if self.data is None: #pragma: no cover + return None + return sha256(self._serialize(self.data)).digest() + + def _update_data_hash(self): + self._data_checksum = self._get_data_checksum() + + def _create_new(self, password): + self.data = {} + self._set_hash(password) + + def _set_hash(self, password): + if password is None: + self._hash = None + else: + self._hash = self._hash_password(password) + + def _save_file(self): + assert self.read_only == False + data = self._serialize(self.data) + enc_data = self._encrypt_file(data) + + magic = self.MAGIC_UNENC if data is enc_data else self.MAGIC_ENC + self._write_file(magic + enc_data) + self._update_data_hash() + + def _load_file(self, password): + data = self._read_file() + assert len(self.MAGIC_ENC) == len(self.MAGIC_UNENC) == 8 + magic = data[:8] + + if magic not in (self.MAGIC_ENC, self.MAGIC_UNENC): + raise StorageError("File does not appear to be a joinmarket wallet.") + + data = data[8:] + + if magic == self.MAGIC_ENC: + if password is None: + raise StorageError("Password required to open wallet.") + data = self._decrypt_file(password, data) + else: + assert magic == self.MAGIC_UNENC + + self.data = self._deserialize(data) + self._update_data_hash() + + def _write_file(self, data): + assert self.read_only is False + + if not os.path.exists(self.path): + # newly created storage + with open(self.path, 'wb') as fh: + fh.write(data) + return + + # using a tmpfile ensures the write is atomic + tmpfile = '{}.tmp'.format(self.path) + + with open(tmpfile, 'wb') as fh: + shutil.copystat(self.path, tmpfile) + fh.write(data) + + #FIXME: behaviour with symlinks might be weird + shutil.move(tmpfile, self.path) + + def _read_file(self): + # this method mainly exists for easier mocking + with open(self.path, 'rb') as fh: + return fh.read() + + @staticmethod + def _serialize(data): + return bencoder.bencode(data) + + @staticmethod + def _deserialize(data): + return bencoder.bdecode(data) + + def _encrypt_file(self, data): + if not self.is_encrypted(): + return data + + iv = get_random_bytes(16) + container = { + b'enc': {b'salt': self._hash.salt, b'iv': iv}, + b'data': self._encrypt(data, iv) + } + return self._serialize(container) + + def _decrypt_file(self, password, data): + assert password is not None + + container = self._deserialize(data) + assert b'enc' in container + assert b'data' in container + + self._hash = self._hash_password(password, container[b'enc'][b'salt']) + + return self._decrypt(container[b'data'], container[b'enc'][b'iv']) + + def _encrypt(self, data, iv): + encrypter = pyaes.Encrypter( + pyaes.AESModeOfOperationCBC(self._hash.hash, iv=iv)) + enc_data = encrypter.feed(self.MAGIC_DETECT_ENC + data) + enc_data += encrypter.feed() + + return enc_data + + def _decrypt(self, data, iv): + decrypter = pyaes.Decrypter( + pyaes.AESModeOfOperationCBC(self._hash.hash, iv=iv)) + try: + dec_data = decrypter.feed(data) + dec_data += decrypter.feed() + except ValueError: + # in most "wrong password" cases the pkcs7 padding will be wrong + raise StoragePasswordError("Wrong password.") + + if not dec_data.startswith(self.MAGIC_DETECT_ENC): + raise StoragePasswordError("Wrong password.") + return dec_data[len(self.MAGIC_DETECT_ENC):] + + @classmethod + def _hash_password(cls, password, salt=None): + return Argon2Hash(password, salt, + hash_len=cls.ENC_KEY_BYTES, salt_len=cls.SALT_LENGTH) + + def _create_lock(self): + if self.read_only: + return + self._lock_file = '{}.lock'.format(self.path) + if os.path.exists(self._lock_file): + self._lock_file = None + raise StorageError("File is currently in use. If this is a " + "leftover from a crashed instance you need to " + "remove the lock file manually") + #FIXME: in python >=3.3 use mode xb + with open(self._lock_file, 'wb'): + pass + + atexit.register(self.close) + + def _remove_lock(self): + if self._lock_file: + os.remove(self._lock_file) + self._lock_file = None + + def close(self): + if not self.read_only and self.was_changed(): + self._save_file() + self._remove_lock() + self.read_only = True + + def __del__(self): + self.close() + + +class VolatileStorage(Storage): + """ + Storage that is never actually written to disk and only kept in memory. + + This exists for easier testing. + """ + + def __init__(self, password=None, data=None): + self.file_data = None + super(VolatileStorage, self).__init__('VOLATILE', password, + create=True) + if data: + self.file_data = data + self._load_file(password) + + def _create_lock(self): + pass + + def _remove_lock(self): + pass + + def _write_file(self, data): + self.file_data = data + + def _read_file(self): + return self.file_data diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index cb220d8..d0330da 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1,29 +1,62 @@ -from __future__ import print_function -import json -import os -import pprint -import sys -import datetime -from decimal import Decimal +from __future__ import print_function, absolute_import, division, unicode_literals + +from ConfigParser import NoOptionError +import warnings +import functools +import collections +import numbers +from binascii import hexlify, unhexlify +from datetime import datetime +from copy import deepcopy from mnemonic import Mnemonic -from ConfigParser import NoSectionError -from getpass import getpass +from hashlib import sha256 +from itertools import chain +from decimal import Decimal + + +from .configure import jm_single +from .support import select_gradual, select_greedy, select_greediest, \ + select +from .cryptoengine import BTC_P2PKH, BTC_P2SH_P2WPKH, TYPE_P2PKH, \ + TYPE_P2SH_P2WPKH +from .support import get_random_bytes +from . import mn_encode, mn_decode, btc + -import btc -from jmclient.slowaes import encryptData, decryptData -from jmclient.blockchaininterface import BitcoinCoreInterface, RegtestBitcoinCoreInterface -from jmclient.configure import jm_single, get_network, get_p2pk_vbyte, get_p2sh_vbyte -from jmbase.support import get_log -from jmclient.support import select_gradual, select_greedy,select_greediest, select +""" +transaction dict format: + { + 'version': int, + 'locktime': int, + 'ins': [ + { + 'outpoint': { + 'hash': bytes, + 'index': int + }, + 'script': bytes, + 'sequence': int, + 'txinwitness': [bytes] + } + ], + 'outs': [ + { + 'script': bytes, + 'value': int + } + ] + } +""" -log = get_log() -JM_WALLET_P2PKH = "00" -JM_WALLET_SW_P2SH_P2WPKH = "01" +def _int_to_bytestr(i): + return str(i).encode('ascii') + class WalletError(Exception): pass + def estimate_tx_fee(ins, outs, txtype='p2pkh'): '''Returns an estimate of the number of satoshis required for a transaction with the given number of inputs and outputs, @@ -38,7 +71,6 @@ def estimate_tx_fee(ins, outs, txtype='p2pkh'): str(absurd_fee) + ", quitting.") if txtype in ['p2pkh', 'p2shMofN']: tx_estimated_bytes = btc.estimate_tx_size(ins, outs, txtype) - log.debug("Estimated transaction size: "+str(tx_estimated_bytes)) return int((tx_estimated_bytes * fee_per_kb)/Decimal(1000.0)) elif txtype=='p2sh-p2wpkh': witness_estimate, non_witness_estimate = btc.estimate_tx_size( @@ -48,526 +80,1261 @@ def estimate_tx_fee(ins, outs, txtype='p2pkh'): else: raise NotImplementedError("Txtype: " + txtype + " not implemented.") -def create_wallet_file(pwd, seed): - password_key = btc.bin_dbl_sha256(pwd) - encrypted_seed = encryptData(password_key, seed.decode('hex')) - timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") - return json.dumps({'creator': 'joinmarket project', - 'creation_time': timestamp, - 'encrypted_seed': encrypted_seed.encode('hex'), - 'network': get_network()}) -class AbstractWallet(object): - """ - Abstract wallet for use with JoinMarket - Mostly written with Wallet in mind, the default JoinMarket HD wallet - """ +#FIXME: move this to a utilities file? +def deprecated(func): + @functools.wraps(func) + def wrapped(*args, **kwargs): + warnings.warn("Call to deprecated function {}.".format(func.__name__), + category=DeprecationWarning, stacklevel=2) + return func(*args, **kwargs) - def __init__(self): - self.max_mix_depth = 0 - self.unspent = None - self.utxo_selector = select - try: - config = jm_single().config - if config.get("POLICY", "merge_algorithm") == "gradual": - self.utxo_selector = select_gradual - elif config.get("POLICY", "merge_algorithm") == "greedy": - self.utxo_selector = select_greedy - elif config.get("POLICY", "merge_algorithm") == "greediest": - self.utxo_selector = select_greediest - elif config.get("POLICY", "merge_algorithm") != "default": - raise Exception("Unknown merge algorithm") - except NoSectionError: - pass + return wrapped - def get_key_from_addr(self, addr): - return None + +class UTXOManager(object): + STORAGE_KEY = b'utxo' + TXID_LEN = 32 + + def __init__(self, storage, merge_func): + self.storage = storage + self.selector = merge_func + # {mixdexpth: {(txid, index): (path, value)}} + self._utxo = None + self._load_storage() + assert self._utxo is not None + + @classmethod + def initialize(cls, storage): + storage.data[cls.STORAGE_KEY] = {} + + def _load_storage(self): + assert isinstance(self.storage.data[self.STORAGE_KEY], dict) + + self._utxo = collections.defaultdict(dict) + for md, data in self.storage.data[self.STORAGE_KEY].items(): + md = int(md) + md_data = self._utxo[md] + for utxo, value in data.items(): + txid = utxo[:self.TXID_LEN] + index = int(utxo[self.TXID_LEN:]) + md_data[(txid, index)] = value + + def save(self, write=True): + new_data = {} + self.storage.data[self.STORAGE_KEY] = new_data + for md, data in self._utxo.items(): + md = _int_to_bytestr(md) + new_data[md] = {} + # storage keys must be bytes() + for (txid, index), value in data.items(): + new_data[md][txid + _int_to_bytestr(index)] = value + if write: + self.storage.save() + + def reset(self): + self._utxo = collections.defaultdict(dict) + + def have_utxo(self, txid, index): + for md in self._utxo: + if (txid, index) in self._utxo[md]: + return md + return False + + def remove_utxo(self, txid, index, mixdepth): + assert isinstance(txid, bytes) + assert len(txid) == self.TXID_LEN + assert isinstance(index, numbers.Integral) + assert isinstance(mixdepth, numbers.Integral) + + return self._utxo[mixdepth].pop((txid, index)) + + def add_utxo(self, txid, index, path, value, mixdepth): + assert isinstance(txid, bytes) + assert len(txid) == self.TXID_LEN + assert isinstance(index, numbers.Integral) + assert isinstance(value, numbers.Integral) + assert isinstance(mixdepth, numbers.Integral) + + self._utxo[mixdepth][(txid, index)] = (path, value) + + def select_utxos(self, mixdepth, amount, utxo_filter=()): + assert isinstance(mixdepth, numbers.Integral) + utxos = self._utxo[mixdepth] + available = [{'utxo': utxo, 'value': val} + for utxo, (addr, val) in utxos.items() if utxo not in utxo_filter] + selected = self.selector(available, amount) + return {s['utxo']: {'path': utxos[s['utxo']][0], + 'value': utxos[s['utxo']][1]} + for s in selected} + + def get_balance_by_mixdepth(self): + balance_dict = collections.defaultdict(int) + for mixdepth, utxomap in self._utxo.items(): + value = sum(x[1] for x in utxomap.values()) + balance_dict[mixdepth] = value + return balance_dict def get_utxos_by_mixdepth(self): - return None + return deepcopy(self._utxo) + + def __eq__(self, o): + return self._utxo == o._utxo and \ + self.selector is o.selector + + +class BaseWallet(object): + TYPE = None + + MERGE_ALGORITHMS = { + 'default': select, + 'gradual': select_gradual, + 'greedy': select_greedy, + 'greediest': select_greediest + } + + _ENGINES = { + TYPE_P2PKH: BTC_P2PKH, + TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH + } + + _ENGINE = None + + def __init__(self, storage, gap_limit=6, merge_algorithm_name=None): + # to be defined by inheriting classes + assert self.TYPE is not None + assert self._ENGINE is not None + + self.merge_algorithm = self._get_merge_algorithm(merge_algorithm_name) + self.gap_limit = gap_limit + self._storage = storage + self._utxos = None + self.max_mixdepth = None + self.network = None + + # {script: path}, should always hold mappings for all "known" keys + self._script_map = {} + + self._load_storage() + + assert self._utxos is not None + assert self.max_mixdepth is not None + assert self.max_mixdepth >= 0 + assert self.network in ('mainnet', 'testnet') + + @property + @deprecated + def max_mix_depth(self): + return self.max_mixdepth + + @property + @deprecated + def gaplimit(self): + return self.gap_limit + + def _load_storage(self): + """ + load data from storage + """ + if self._storage.data[b'wallet_type'] != self.TYPE: + raise Exception("Wrong class to initialize wallet of type {}." + .format(self.TYPE)) + self.network = self._storage.data[b'network'].decode('ascii') + self.max_mixdepth = self._storage.data[b'max_mixdepth'] + self._utxos = UTXOManager(self._storage, self.merge_algorithm) + + def save(self): + """ + Write data to associated storage object and trigger persistent update. + """ + self._storage.data[b'max_mixdepth'] = self.max_mixdepth + self._storage.save() + + @classmethod + def initialize(cls, storage, network, max_mixdepth=2, timestamp=None, + write=True): + """ + Initialize wallet in an empty storage. Must be used on a storage object + before creating a wallet object with it. + + args: + storage: a Storage object + network: str, network we are on, 'mainnet' or 'testnet' + max_mixdepth: int, number of the highest mixdepth + timestamp: bytes or None, defaults to the current time + write: execute storage.save() + """ + assert network in ('mainnet', 'testnet') + assert max_mixdepth >= 0 + + if storage.data != {}: + # prevent accidentally overwriting existing wallet + raise WalletError("Refusing to initialize wallet in non-empty " + "storage.") + + if not timestamp: + timestamp = datetime.now().strftime('%Y/%m/%d %H:%M:%S') + + storage.data[b'network'] = network.encode('ascii') + storage.data[b'max_mixdepth'] = max_mixdepth + storage.data[b'created'] = timestamp.encode('ascii') + storage.data[b'wallet_type'] = cls.TYPE + + UTXOManager.initialize(storage) + + if write: + storage.save() + + def get_txtype(self): + """ + use TYPE constant instead if possible + """ + if self.TYPE == TYPE_P2PKH: + return 'p2pkh' + elif self.TYPE == TYPE_P2SH_P2WPKH: + return 'p2sh-p2wpkh' + assert False + + def sign_tx(self, tx, scripts, **kwargs): + """ + Add signatures to transaction for inputs referenced by scripts. + + args: + tx: transaction dict + scripts: {input_index: (output_script, amount)} + kwargs: additional arguments for engine.sign_transaction + returns: + input transaction dict with added signatures + """ + for index, (script, amount) in scripts.items(): + assert amount > 0 + path = self.script_to_path(script) + privkey, engine = self._get_priv_from_path(path) + engine.sign_transaction(tx, index, privkey, amount, **kwargs) + return tx + + @deprecated + def get_key_from_addr(self, addr): + """ + There should be no reason for code outside the wallet to need a privkey. + """ + script = self._ENGINE.address_to_script(addr) + path = self.script_to_path(script) + privkey = self._get_priv_from_path(path)[0] + return hexlify(privkey) - def get_external_addr(self, mixing_depth): + def get_external_addr(self, mixdepth): """ Return an address suitable for external distribution, including funding the wallet from other sources, or receiving payments or donations. JoinMarket will never generate these addresses for internal use. """ - return None + script = self.get_external_script(mixdepth) + return self.script_to_addr(script) - def get_internal_addr(self, mixing_depth): + def get_internal_addr(self, mixdepth): """ Return an address for internal usage, as change addresses and when participating in transactions initiated by other parties. """ - return None + script = self.get_internal_script(mixdepth) + return self.script_to_addr(script) + + def get_external_script(self, mixdepth): + return self.get_new_script(mixdepth, False) + + def get_internal_script(self, mixdepth): + return self.get_new_script(mixdepth, True) @classmethod - def pubkey_to_address(cls, pubkey): - return None + def addr_to_script(cls, addr): + return cls._ENGINE.address_to_script(addr) - def update_cache_index(self): - pass + @classmethod + def pubkey_to_addr(cls, pubkey): + return cls._ENGINE.pubkey_to_address(pubkey) - def remove_old_utxos(self, tx): - pass + def script_to_addr(self, script): + assert self.is_known_script(script) + path = self.script_to_path(script) + engine = self._get_priv_from_path(path)[1] + return engine.script_to_address(script) - def add_new_utxos(self, tx, txid): - pass + @deprecated + def get_key(self, mixdepth, internal, index): + raise NotImplementedError() - def select_utxos(self, mixdepth, amount, utxo_filter=None): - if utxo_filter is None: - utxo_filter = [] - utxo_list = self.get_utxos_by_mixdepth()[mixdepth] - unspent = [{'utxo': utxo, - 'value': addrval['value']} - for utxo, addrval in utxo_list.iteritems() if utxo not in utxo_filter] - inputs = self.utxo_selector(unspent, amount) - log.debug('for mixdepth={} amount={} selected:'.format( - mixdepth, amount)) - log.debug(pprint.pformat(inputs)) - return dict([(i['utxo'], {'value': i['value'], - 'address': utxo_list[i['utxo']]['address']}) - for i in inputs]) + def get_addr(self, mixdepth, internal, index): + script = self.get_script(mixdepth, internal, index) + return self.script_to_addr(script) - def get_balance_by_mixdepth(self, verbose=True): - mix_balance = {} - for m in range(self.max_mix_depth): - mix_balance[m] = 0 - for mixdepth, utxos in self.get_utxos_by_mixdepth(verbose).iteritems(): - mix_balance[mixdepth] = sum( - [addrval['value'] for addrval in utxos.values()]) - return mix_balance - -class Wallet(AbstractWallet): - def __init__(self, - seedarg, - pwd, - max_mix_depth=2, - gaplimit=6, - extend_mixdepth=False, - storepassword=False, - wallet_dir=None): - super(Wallet, self).__init__() - self.vflag = JM_WALLET_P2PKH - self.max_mix_depth = max_mix_depth - self.storepassword = storepassword - # key is address, value is (mixdepth, forchange, index) if mixdepth = - # -1 it's an imported key and index refers to imported_privkeys - self.addr_cache = {} - self.unspent = {} - self.spent_utxos = [] - self.imported_privkeys = {} - self.seed = self.wallet_data_to_seed( - self.read_wallet_file_data(seedarg, pwd, wallet_dir=wallet_dir)) - if not self.seed: - raise WalletError("Failed to decrypt wallet") - if extend_mixdepth and len(self.index_cache) > max_mix_depth: - self.max_mix_depth = len(self.index_cache) - self.gaplimit = gaplimit - mixing_depth_keys = self.get_mixing_depth_keys(self.get_master_key()) - self.keys = [(btc.bip32_ckd(m, 0), btc.bip32_ckd(m, 1)) - for m in mixing_depth_keys] - - # self.index = [[0, 0]]*max_mix_depth - self.index = [] - for i in range(self.max_mix_depth): - self.index.append([0, 0]) - - def get_master_key(self): - if not self.seed: - raise Exception("Cannot extract master key of wallet, no seed.") - #Legacy used the seed in hex - if not isinstance(self, SegwitWallet): - bip32seed = self.seed - else: - bip32seed = self.seed.decode('hex') - return btc.bip32_master_key(bip32seed, (btc.MAINNET_PRIVATE if get_network( - ) == 'mainnet' else btc.TESTNET_PRIVATE)) + def get_addr_path(self, path): + script = self.get_script_path(path) + return self.script_to_addr(script) - def get_mixing_depth_keys(self, master): - """legacy path is m/0/n for n 0..N mixing depths + def get_new_addr(self, mixdepth, internal): + """ + use get_external_addr/get_internal_addr """ - m_0 = btc.bip32_ckd(master, 0) - return [btc.bip32_ckd(m_0, c) for c in range(self.max_mix_depth)] + script = self.get_new_script(mixdepth, internal) + return self.script_to_addr(script) - def get_root_path(self): - return "m/0" + def get_new_script(self, mixdepth, internal): + raise NotImplementedError() - def wallet_data_to_seed(self, entropy): - """for base/legacy wallet type, this is a passthrough. - for bip39 style wallets, this will convert from one to the other - """ - if entropy is None: - return None - #Feature for testnet testing: if we are using direct command line - #brainwallets (as we do for regtest), strip the flag. - if entropy.startswith("FAKESEED"): - entropy = entropy[8:] - return entropy + def get_wif(self, mixdepth, internal, index): + return self.get_wif_path(self.get_path(mixdepth, internal, index)) - def get_txtype(self): - """Return string defining wallet type - for purposes of transaction size estimates + def get_wif_path(self, path): + priv, engine = self._get_priv_from_path(path) + return engine.privkey_to_wif(priv) + + def get_path(self, mixdepth=None, internal=None, index=None): + raise NotImplementedError() + + def get_details(self, path): """ - return 'p2pkh' + Return mixdepth, internal, index for a given path + + args: + path: wallet path + returns: + tuple (mixdepth, type, index) - def sign(self, tx, i, priv, amount): - """Sign a transaction for pushing - onto the network. The amount field - is not used in this case (p2pkh) + type is one of 0, 1, 'imported' """ - return btc.sign(tx, i, priv) + raise NotImplementedError() - @classmethod - def script_to_address(cls, script): - """Return the address for a given output script, - which will be p2pkh for the default Wallet object, - and reading the correct network byte from the config. + @deprecated + def update_cache_index(self): + """ + Deprecated alias for save() """ - return btc.script_to_address(script, cls.get_vbyte()) + self.save() - @classmethod - def pubkey_to_address(cls, pubkey): - return btc.pubkey_to_address(pubkey, cls.get_vbyte()) - - def read_wallet_file_data(self, filename, pwd=None, wallet_dir=None): - self.path = None - wallet_dir = wallet_dir if wallet_dir else 'wallets' - self.index_cache = [[0, 0]] * self.max_mix_depth - path = os.path.join(wallet_dir, filename) - if not os.path.isfile(path): - if get_network() == 'testnet': - log.debug('filename interpreted as seed, only available in ' - 'testnet because this probably has lower entropy') - return "FAKESEED" + filename - else: - raise IOError('wallet file not found') - if not pwd: - log.info("Password required for non-testnet seed wallet") - return None - self.path = path - fd = open(path, 'r') - walletfile = fd.read() - fd.close() - walletdata = json.loads(walletfile) - if walletdata['network'] != get_network(): - raise ValueError('wallet network(%s) does not match ' - 'joinmarket configured network(%s)' % ( - walletdata['network'], get_network())) - if 'index_cache' in walletdata: - self.index_cache = walletdata['index_cache'] - if self.max_mix_depth > len(self.index_cache): - #This can happen e.g. in tumbler when we need more mixdepths - #than currently exist. Since we have no info for those extra - #depths, we must default to (0,0) (but sync should find used - #adddresses). - self.index_cache += [[0,0]] * ( - self.max_mix_depth - len(self.index_cache)) - password_key = btc.bin_dbl_sha256(pwd) - if 'encrypted_seed' in walletdata: #accept old field name - encrypted_entropy = walletdata['encrypted_seed'] - elif 'encrypted_entropy' in walletdata: - encrypted_entropy = walletdata['encrypted_entropy'] - try: - decrypted_entropy = decryptData( - password_key, - encrypted_entropy.decode('hex')).encode('hex') - # there is a small probability of getting a valid PKCS7 - # padding by chance from a wrong password; sanity check the - # seed length - if len(decrypted_entropy) != 32: - raise ValueError - except ValueError: - log.info('Incorrect password') - return None - - if 'encrypted_mnemonic_extension' in walletdata: - try: - cleartext = decryptData(password_key, - walletdata['encrypted_mnemonic_extension'].decode('hex')) - #theres a small chance of not getting a ValueError from the wrong - # password so also check the sum - if cleartext[-9] != '\xff': - raise ValueError - chunks = cleartext.split('\xff') - if len(chunks) < 3 or cleartext[-8:] != btc.dbl_sha256(chunks[1])[:8]: - raise ValueError - mnemonic_extension = chunks[1] - except ValueError: - log.info('incorrect password') - return None - else: - mnemonic_extension = None - - if self.storepassword: - self.password_key = password_key - self.walletdata = walletdata - if 'imported_keys' in walletdata: - for epk_m in walletdata['imported_keys']: - privkey = decryptData( - password_key, - epk_m['encrypted_privkey'].decode( 'hex')).encode('hex') - #Imported keys are stored as 32 byte strings only, so the - #second version below is sufficient, really. - if len(privkey) != 64: - raise Exception( - "Unexpected privkey format; already compressed?:" + privkey) - privkey += "01" - if epk_m['mixdepth'] not in self.imported_privkeys: - self.imported_privkeys[epk_m['mixdepth']] = [] - self.addr_cache[btc.privtoaddr( - privkey, magicbyte=get_p2pk_vbyte())] = (epk_m['mixdepth'], -1, - len(self.imported_privkeys[epk_m['mixdepth']])) - self.imported_privkeys[epk_m['mixdepth']].append(privkey) - - if mnemonic_extension: - return (decrypted_entropy, mnemonic_extension) - else: - return decrypted_entropy + @deprecated + def remove_old_utxos(self, tx): + tx = deepcopy(tx) + for inp in tx['ins']: + inp['outpoint']['hash'] = unhexlify(inp['outpoint']['hash']) - def update_cache_index(self): - if not self.path: - return - if not os.path.isfile(self.path): - return - fd = open(self.path, 'r') - walletfile = fd.read() - fd.close() - walletdata = json.loads(walletfile) - walletdata['index_cache'] = self.index - walletfile = json.dumps(walletdata) - fd = open(self.path, 'w') - fd.write(walletfile) - fd.close() - - def get_key(self, mixing_depth, forchange, i): - return btc.bip32_extract_key(btc.bip32_ckd( - self.keys[mixing_depth][forchange], i)) - - def get_addr(self, mixing_depth, forchange, i): - return btc.privtoaddr( - self.get_key(mixing_depth, forchange, i), magicbyte=get_p2pk_vbyte()) - - def get_new_addr(self, mixing_depth, forchange, import_required=False): - index = self.index[mixing_depth] - addr = self.get_addr(mixing_depth, forchange, index[forchange]) - self.addr_cache[addr] = (mixing_depth, forchange, index[forchange]) - index[forchange] += 1 - # self.update_cache_index() - bc_interface = jm_single().bc_interface - if isinstance(bc_interface, BitcoinCoreInterface) or isinstance( - bc_interface, RegtestBitcoinCoreInterface) or import_required: - # do not import in the middle of sync_wallet() - if bc_interface.wallet_synced: - if bc_interface.rpc('getaccount', [addr]) == '': - log.debug('importing address ' + addr + ' to bitcoin core') - bc_interface.rpc( - 'importaddress', - [addr, bc_interface.get_wallet_name(self), False]) - return addr - - def get_external_addr(self, mixing_depth): - return self.get_new_addr(mixing_depth, 0) - - def get_internal_addr(self, mixing_depth): - return self.get_new_addr(mixing_depth, 1) + ret = self.remove_old_utxos_(tx) - def get_key_from_addr(self, addr): - if addr not in self.addr_cache: - return None - ac = self.addr_cache[addr] - if ac[1] >= 0: - return self.get_key(*ac) - else: - return self.imported_privkeys[ac[0]][ac[2]] + removed_utxos = {} + for (txid, index), val in ret.items(): + val['address'] = self.get_addr_path(val['path']) + removed_utxos[hexlify(txid) + ':' + str(index)] = val + return removed_utxos - def remove_old_utxos(self, tx): + def remove_old_utxos_(self, tx): + """ + Remove all own inputs of tx from internal utxo list. + + args: + tx: transaction dict + returns: + {(txid, index): {'script': bytes, 'value': int} for all removed utxos + """ removed_utxos = {} - for ins in tx['ins']: - utxo = ins['outpoint']['hash'] + ':' + str(ins['outpoint']['index']) - if utxo not in self.unspent: + for inp in tx['ins']: + txid, index = inp['outpoint']['hash'], inp['outpoint']['index'] + md = self._utxos.have_utxo(txid, index) + if md is False: continue - removed_utxos[utxo] = self.unspent[utxo] - del self.unspent[utxo] - log.debug('removed utxos, wallet now is \n' + pprint.pformat( - self.get_utxos_by_mixdepth(verbose=False))) - self.spent_utxos += removed_utxos.keys() + path, value = self._utxos.remove_utxo(txid, index, md) + script = self.get_script_path(path) + removed_utxos[(txid, index)] = {'script': script, + 'path': path, + 'value': value} return removed_utxos - @staticmethod - def get_vbyte(): - return get_p2pk_vbyte() - + @deprecated def add_new_utxos(self, tx, txid): + tx = deepcopy(tx) + for out in tx['outs']: + out['script'] = unhexlify(out['script']) + + ret = self.add_new_utxos_(tx, unhexlify(txid)) + + added_utxos = {} + for (txid_bin, index), val in ret.items(): + addr = self.get_addr_path(val['path']) + val['address'] = addr + added_utxos[txid + ':' + str(index)] = val + return added_utxos + + def add_new_utxos_(self, tx, txid): + """ + Add all outputs of tx for this wallet to internal utxo list. + + args: + tx: transaction dict + returns: + {(txid, index): {'script': bytes, 'path': tuple, 'value': int} + for all added utxos + """ + assert isinstance(txid, bytes) and len(txid) == self._utxos.TXID_LEN added_utxos = {} for index, outs in enumerate(tx['outs']): - addr = btc.script_to_address(outs['script'], self.get_vbyte()) - if addr not in self.addr_cache: + try: + self.add_utxo(txid, index, outs['script'], outs['value']) + except WalletError: continue - addrdict = {'address': addr, 'value': outs['value']} - utxo = txid + ':' + str(index) - added_utxos[utxo] = addrdict - self.unspent[utxo] = addrdict - log.debug('added utxos, wallet now is \n' + pprint.pformat( - self.get_utxos_by_mixdepth())) + + path = self.script_to_path(outs['script']) + added_utxos[(txid, index)] = {'script': outs['script'], + 'path': path, + 'value': outs['value']} return added_utxos + def add_utxo(self, txid, index, script, value): + assert isinstance(txid, bytes) + assert isinstance(index, int) + assert isinstance(script, bytes) + assert isinstance(value, int) + + if script not in self._script_map: + raise WalletError("Tried to add UTXO for unknown key to wallet.") + + path = self.script_to_path(script) + mixdepth = self._get_mixdepth_from_path(path) + self._utxos.add_utxo(txid, index, path, value, mixdepth) + + @deprecated + def select_utxos(self, mixdepth, amount, utxo_filter=None): + utxo_filter_new = None + if utxo_filter: + utxo_filter_new = [(unhexlify(utxo[:64]), int(utxo[65:])) + for utxo in utxo_filter] + ret = self.select_utxos_(mixdepth, amount, utxo_filter_new) + ret_conv = {} + for utxo, data in ret.items(): + addr = self.get_addr_path(data['path']) + utxo_txt = hexlify(utxo[0]) + ':' + str(utxo[1]) + ret_conv[utxo_txt] = {'address': addr, 'value': data['value']} + return ret_conv + + def select_utxos_(self, mixdepth, amount, utxo_filter=None): + """ + args: + mixdepth: int, mixdepth to select utxos from + amount: int, total minimum amount of all selected utxos + utxo_filter: list of (txid, index), utxos not to select + + returns: + {(txid, index): {'script': bytes, 'path': tuple, 'value': int}} + """ + assert isinstance(mixdepth, numbers.Integral) + assert isinstance(amount, numbers.Integral) + + if not utxo_filter: + utxo_filter = () + for i in utxo_filter: + assert len(i) == 2 + assert isinstance(i[0], bytes) + assert isinstance(i[1], numbers.Integral) + ret = self._utxos.select_utxos(mixdepth, amount, utxo_filter) + + for data in ret.values(): + data['script'] = self.get_script_path(data['path']) + + return ret + + def reset_utxos(self): + self._utxos.reset() + + def get_balance_by_mixdepth(self, verbose=True): + # TODO: verbose + return self._utxos.get_balance_by_mixdepth() + + @deprecated def get_utxos_by_mixdepth(self, verbose=True): + # TODO: verbose + ret = self.get_utxos_by_mixdepth_() + + utxos_conv = collections.defaultdict(dict) + for md, utxos in ret.items(): + for utxo, data in utxos.items(): + utxo_str = hexlify(utxo[0]) + ':' + str(utxo[1]) + addr = self.get_addr_path(data['path']) + data['address'] = addr + utxos_conv[md][utxo_str] = data + return utxos_conv + + def get_utxos_by_mixdepth_(self): + """ + returns: + {mixdepth: {(txid, index): + {'script': bytes, 'path': tuple, 'value': int}}} """ - returns a list of utxos sorted by different mix levels - """ - mix_utxo_list = {} - for m in range(self.max_mix_depth): - mix_utxo_list[m] = {} - for utxo, addrvalue in self.unspent.iteritems(): - mixdepth = self.addr_cache[addrvalue['address']][0] - if mixdepth not in mix_utxo_list: - mix_utxo_list[mixdepth] = {} - mix_utxo_list[mixdepth][utxo] = addrvalue - if verbose: - log.debug('get_utxos_by_mixdepth = \n' + pprint.pformat(mix_utxo_list)) - return mix_utxo_list - -class Bip39Wallet(Wallet): - """Using python module `mnemonic` to implement - BIP39, English only: - https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki + mix_utxos = self._utxos.get_utxos_by_mixdepth() + + script_utxos = collections.defaultdict(dict) + for md, data in mix_utxos.items(): + for utxo, (path, value) in data.items(): + script = self.get_script_path(path) + script_utxos[md][utxo] = {'script': script, + 'path': path, + 'value': value} + return script_utxos + + @classmethod + def _get_merge_algorithm(cls, algorithm_name=None): + if not algorithm_name: + try: + algorithm_name = jm_single().config.get('POLICY', + 'merge_algorithm') + except NoOptionError: + algorithm_name = 'default' + + alg = cls.MERGE_ALGORITHMS.get(algorithm_name) + if alg is None: + raise Exception("Unknown merge algorithm: '{}'." + "".format(algorithm_name)) + return alg + + def _get_mixdepth_from_path(self, path): + raise NotImplementedError() + + def get_script_path(self, path): + """ + internal note: This is the final sink for all operations that somehow + need to derive a script. If anything goes wrong when deriving a + script this is the place to look at. + + args: + path: wallet path + returns: + script + """ + raise NotImplementedError() + + def get_script(self, mixdepth, internal, index): + path = self.get_path(mixdepth, internal, index) + return self.get_script_path(path) + + def _get_priv_from_path(self, path): + raise NotImplementedError() + + def get_path_repr(self, path): + """ + Get a human-readable representation of the wallet path. + + args: + path: path tuple + returns: + str + """ + raise NotImplementedError() + + def path_repr_to_path(self, pathstr): + """ + Convert a human-readable path representation to internal representation. + + args: + pathstr: str + returns: + path tuple + """ + raise NotImplementedError() + + def get_next_unused_index(self, mixdepth, internal): + """ + Get the next index for public scripts/addresses not yet handed out. + + returns: + int >= 0 + """ + raise NotImplementedError() + + def get_mnemonic_words(self): + """ + Get mnemonic seed words for master key. + + returns: + mnemonic_seed, seed_extension + mnemonic_seed is a space-separated str of words + seed_extension is a str or None + """ + raise NotImplementedError() + + def sign_message(self, message, path): + """ + Sign the message using the key referenced by path. + + args: + message: bytes + path: path tuple + returns: + signature as base64-encoded string + """ + priv, engine = self._get_priv_from_path(path) + return engine.sign_message(priv, message) + + def get_wallet_id(self): + """ + Get a human-readable identifier for the wallet. + + returns: + str + """ + raise NotImplementedError() + + def yield_imported_paths(self, mixdepth): + """ + Get an iterator for all imported keys in given mixdepth. + + params: + mixdepth: int + returns: + iterator of wallet paths + """ + return iter([]) + + def is_known_addr(self, addr): + """ + Check if address is known to belong to this wallet. + + params: + addr: str + returns: + bool + """ + script = self.addr_to_script(addr) + return script in self._script_map + + def is_known_script(self, script): + """ + Check if script is known to belong to this wallet. + + params: + script: bytes + returns: + bool + """ + assert isinstance(script, bytes) + return script in self._script_map + + def get_addr_mixdepth(self, addr): + script = self.addr_to_script(addr) + return self.get_script_mixdepth(script) + + def get_script_mixdepth(self, script): + path = self.script_to_path(script) + return self._get_mixdepth_from_path(path) + + def yield_known_paths(self): + """ + Generator for all paths currently known to the wallet + + returns: + path generator + """ + for s in self._script_map.values(): + yield s + + def addr_to_path(self, addr): + script = self.addr_to_script(addr) + return self.script_to_path(script) + + def script_to_path(self, script): + assert script in self._script_map + return self._script_map[script] + + def set_next_index(self, mixdepth, internal, index, force=False): + """ + Set the next index to use when generating a new key pair. + + params: + mixdepth: int + internal: 0/False or 1/True + index: int + force: True if you know the wallet already knows all scripts + up to (excluding) the given index + + Warning: improper use of 'force' will cause undefined behavior! + """ + raise NotImplementedError() + + def close(self): + self._storage.close() + + def __del__(self): + self.close() + + +class ImportWalletMixin(object): """ - def wallet_data_to_seed(self, data): - if data is None: - return None - self.mnemonic_extension = None - if isinstance(data, tuple): - entropy, self.mnemonic_extension = data - else: - entropy = data - if get_network() == "testnet": - if entropy.startswith("FAKESEED"): - return entropy[8:] - self.entropy = entropy.decode('hex') - m = Mnemonic("english") - return m.to_seed(m.to_mnemonic(self.entropy), - '' if not self.mnemonic_extension else self.mnemonic_extension).encode('hex') - -class SegwitWallet(Bip39Wallet): - - """This implements an HD wallet (BIP32), - with address type P2SH/P2WPKH of segwit (BIP141), - using BIP39 mnemonics (see BIP39Wallet), - and the structure is intended as an implementation of BIP49, - which is a derivative of BIP44: - https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki + Mixin for BaseWallet to support importing keys. """ - def __init__(self, seedarg, pwd, max_mix_depth=2, gaplimit=6, - extend_mixdepth=False, storepassword=False, wallet_dir=None): - self.entropy = None - super(SegwitWallet, self).__init__(seedarg, pwd, max_mix_depth, gaplimit, - extend_mixdepth, storepassword, - wallet_dir=wallet_dir) - self.vflag = JM_WALLET_SW_P2SH_P2WPKH - - def get_root_path(self): - testflag = "1'" if get_network() == "testnet" else "0'" - return "m/49'/" + testflag - - def get_mixing_depth_keys(self, master): - pre_root = btc.bip32_ckd(master, 49 + 2**31) - testnet_flag = 1 if get_network() == "testnet" else 0 - root = btc.bip32_ckd(pre_root, testnet_flag + 2**31) - return [btc.bip32_ckd(root, c + 2**31) for c in range(self.max_mix_depth)] - - @staticmethod - def get_vbyte(): - return get_p2sh_vbyte() + _IMPORTED_STORAGE_KEY = b'imported_keys' + _IMPORTED_ROOT_PATH = b'imported' - def get_txtype(self): - """Return string defining wallet type - for purposes of transaction size estimates + def __init__(self, storage, gap_limit=6, merge_algorithm_name=None): + # {mixdepth: [(privkey, type)]} + self._imported = None + # path is (_IMPORTED_ROOT_PATH, mixdepth, key_index) + super(ImportWalletMixin, self).__init__(storage, gap_limit, + merge_algorithm_name) + + def _load_storage(self): + super(ImportWalletMixin, self)._load_storage() + self._imported = collections.defaultdict(list) + for md, keys in self._storage.data[self._IMPORTED_STORAGE_KEY].items(): + md = int(md) + self._imported[md] = keys + for index, (key, key_type) in enumerate(keys): + if not key: + # imported key was removed + continue + assert key_type in self._ENGINES + self._cache_imported_key(md, key, key_type, index) + + def save(self): + import_data = {} + for md in self._imported: + import_data[_int_to_bytestr(md)] = self._imported[md] + self._storage.data[self._IMPORTED_STORAGE_KEY] = import_data + super(ImportWalletMixin, self).save() + + @classmethod + def initialize(cls, storage, network, max_mixdepth=2, timestamp=None, + write=True, **kwargs): + super(ImportWalletMixin, cls).initialize( + storage, network, max_mixdepth, timestamp, write=False, **kwargs) + storage.data[cls._IMPORTED_STORAGE_KEY] = {} + + if write: + storage.save() + + def import_private_key(self, mixdepth, wif, key_type=None): """ - return 'p2sh-p2wpkh' + Import a private key in WIF format. - def get_addr(self, mixing_depth, forchange, i): - """Construct a p2sh-p2wpkh style address for the - keypair corresponding to mixing depth mixing_depth, - branch forchange and index i + args: + mixdepth: int, mixdepth to import key into + wif: str, private key in WIF format + key_type: int, must match a TYPE_* constant of this module, + used to verify against the key type extracted from WIF + + raises: + WalletError: if key's network does not match wallet network + WalletError: if key is not compressed and type is not P2PKH + WalletError: if key_type does not match data from WIF + + returns: + path of imported key """ - pub = btc.privtopub(self.get_key(mixing_depth, forchange, i)) - return btc.pubkey_to_p2sh_p2wpkh_address(pub, magicbyte=self.get_vbyte()) + if not 0 <= mixdepth <= self.max_mixdepth: + raise WalletError("Mixdepth must be positive and at most {}." + "".format(self.max_mixdepth)) + if key_type is not None and key_type not in self._ENGINES: + raise WalletError("Unsupported key type for imported keys.") - @classmethod - def script_to_address(cls, script): - """Return the address for a given output script, - which will be p2sh-p2wpkh for the segwit (currently). - The underlying witness is however invisible at this layer; - so it's just a p2sh address. + privkey, key_type_wif = self._ENGINE.wif_to_privkey(wif) + + # FIXME: there is no established standard for encoding key type in wif + #if key_type is not None and key_type_wif is not None and \ + # key_type != key_type_wif: + # raise WalletError("Expected key type does not match WIF type.") + + # default to wallet key type if not told otherwise + if key_type is None: + key_type = self.TYPE + #key_type = key_type_wif if key_type_wif is not None else self.TYPE + engine = self._ENGINES[key_type] + + if engine.privkey_to_script(privkey) in self._script_map: + raise WalletError("Cannot import key, already in wallet: {}" + "".format(wif)) + + self._imported[mixdepth].append((privkey, key_type)) + return self._cache_imported_key(mixdepth, privkey, key_type, + len(self._imported[mixdepth]) - 1) + + def remove_imported_key(self, script=None, address=None, path=None): """ - return btc.script_to_address(script, cls.get_vbyte()) + Remove an imported key. Arguments are exclusive. + + args: + script: bytes + address: str + path: path + """ + if sum((bool(script), bool(address), bool(path))) != 1: + raise Exception("Only one of script|address|path may be given.") + + if address: + script = self.addr_to_script(address) + if script: + path = self.script_to_path(script) + + if not path: + raise WalletError("Cannot find key in wallet.") + + if not self._is_imported_path(path): + raise WalletError("Cannot remove non-imported key.") + + assert len(path) == 3 + + if not script: + script = self.get_script_path(path) + + # we need to retain indices + self._imported[path[1]][path[2]] = (b'', -1) + + del self._script_map[script] + + def _cache_imported_key(self, mixdepth, privkey, key_type, index): + engine = self._ENGINES[key_type] + path = (self._IMPORTED_ROOT_PATH, mixdepth, index) + + self._script_map[engine.privkey_to_script(privkey)] = path + + return path + + def _get_mixdepth_from_path(self, path): + if not self._is_imported_path(path): + return super(ImportWalletMixin, self)._get_mixdepth_from_path(path) + + assert len(path) == 3 + return path[1] + + def _get_priv_from_path(self, path): + if not self._is_imported_path(path): + return super(ImportWalletMixin, self)._get_priv_from_path(path) + + assert len(path) == 3 + md, i = path[1], path[2] + assert 0 <= md <= self.max_mixdepth + + if len(self._imported[md]) <= i: + raise WalletError("unknown imported key at {}" + "".format(self.get_path_repr(path))) + + key, key_type = self._imported[md][i] + + if key_type == -1: + raise WalletError("imported key was removed") + + return key, self._ENGINES[key_type] @classmethod - def pubkey_to_address(cls, pubkey): - return btc.pubkey_to_p2sh_p2wpkh_address(pubkey, cls.get_vbyte()) - - def sign(self, tx, i, priv, amount): - """Sign a transaction; the amount field - triggers the segwit style signing. - """ - log.debug("About to sign for this amount: " + str(amount)) - return btc.sign(tx, i, priv, amount=amount) - -class BitcoinCoreWallet(AbstractWallet): #pragma: no cover - def __init__(self, fromaccount): - super(BitcoinCoreWallet, self).__init__() - if not isinstance(jm_single().bc_interface, - BitcoinCoreInterface): - raise RuntimeError('Bitcoin Core wallet can only be used when ' - 'blockchain interface is BitcoinCoreInterface') - self.fromaccount = fromaccount - self.max_mix_depth = 1 + def _is_imported_path(cls, path): + return len(path) == 3 and path[0] == cls._IMPORTED_ROOT_PATH - def get_key_from_addr(self, addr): - self.ensure_wallet_unlocked() - wifkey = jm_single().bc_interface.rpc('dumpprivkey', [addr]) - return btc.from_wif_privkey(wifkey, vbyte=get_p2pk_vbyte()) + def path_repr_to_path(self, pathstr): + spath = pathstr.encode('ascii').split(b'/') + if not self._is_imported_path(spath): + return super(ImportWalletMixin, self).path_repr_to_path(pathstr) - def get_utxos_by_mixdepth(self): - unspent_list = jm_single().bc_interface.rpc('listunspent', []) - result = {0: {}} - for u in unspent_list: - if not u['spendable']: - continue - if self.fromaccount and ( - ('account' not in u) or u['account'] != - self.fromaccount): + return self._IMPORTED_ROOT_PATH, int(spath[1]), int(spath[2]) + + def get_path_repr(self, path): + if not self._is_imported_path(path): + return super(ImportWalletMixin, self).get_path_repr(path) + + assert len(path) == 3 + return 'imported/{}/{}'.format(*path[1:]) + + def yield_imported_paths(self, mixdepth): + assert 0 <= mixdepth <= self.max_mixdepth + + for index in range(len(self._imported[mixdepth])): + if self._imported[mixdepth][index][1] == -1: continue - result[0][u['txid'] + ':' + str(u['vout'])] = { - 'address': u['address'], - 'value': int(Decimal(str(u['amount'])) * Decimal('1e8'))} - return result - - def get_internal_addr(self, mixing_depth): - return jm_single().bc_interface.rpc('getrawchangeaddress', []) - - @staticmethod - def ensure_wallet_unlocked(): - wallet_info = jm_single().bc_interface.rpc('getwalletinfo', []) - if 'unlocked_until' in wallet_info and wallet_info[ - 'unlocked_until'] <= 0: - while True: - password = getpass( - 'Enter passphrase to unlock wallet: ') - if password == '': - raise RuntimeError('Aborting wallet unlock') - try: - # TODO cleanly unlock wallet after use, not with arbitrary timeout - jm_single().bc_interface.rpc( - 'walletpassphrase', [password, 10]) - break - except jm_single().JsonRpcError as exc: - if exc.code != -14: - raise exc - # Wrong passphrase, try again. - -def get_wallet_cls(): - if jm_single().config.get("POLICY", "segwit") == "true": - return SegwitWallet - return Wallet + yield (self._IMPORTED_ROOT_PATH, mixdepth, index) + + def get_details(self, path): + if not self._is_imported_path(path): + return super(ImportWalletMixin, self).get_details(path) + return path[1], 'imported', path[2] + + def get_script_path(self, path): + if not self._is_imported_path(path): + return super(ImportWalletMixin, self).get_script_path(path) + + priv, engine = self._get_priv_from_path(path) + return engine.privkey_to_script(priv) + + +class BIP39WalletMixin(object): + """ + Mixin to use BIP-39 mnemonic seed with BIP32Wallet + """ + _BIP39_EXTENSION_KEY = b'seed_extension' + MNEMONIC_LANG = 'english' + + def _load_storage(self): + super(BIP39WalletMixin, self)._load_storage() + self._entropy_extension = self._storage.data.get(self._BIP39_EXTENSION_KEY) + + @classmethod + def initialize(cls, storage, network, max_mixdepth=2, timestamp=None, + entropy=None, entropy_extension=None, write=True, **kwargs): + super(BIP39WalletMixin, cls).initialize( + storage, network, max_mixdepth, timestamp, entropy, + write=False, **kwargs) + if entropy_extension: + storage.data[cls._BIP39_EXTENSION_KEY] = entropy_extension + + if write: + storage.save() + + def _create_master_key(self): + ent, ext = self.get_mnemonic_words() + m = Mnemonic(self.MNEMONIC_LANG) + return m.to_seed(ent, ext or b'') + + @classmethod + def _verify_entropy(cls, ent): + # every 4-bytestream is a valid entropy for BIP-39 + return ent and len(ent) % 4 == 0 + + def get_mnemonic_words(self): + entropy = super(BIP39WalletMixin, self)._create_master_key() + m = Mnemonic(self.MNEMONIC_LANG) + return m.to_mnemonic(entropy), self._entropy_extension + + @classmethod + def entropy_from_mnemonic(cls, seed): + m = Mnemonic(cls.MNEMONIC_LANG) + seed = seed.lower() + if not m.check(seed): + raise WalletError("Invalid mnemonic seed.") + + ent = m.to_entropy(seed) + + if not cls._verify_entropy(ent): + raise WalletError("Seed entropy is too low.") + + return bytes(ent) + + +class BIP32Wallet(BaseWallet): + _STORAGE_ENTROPY_KEY = b'entropy' + _STORAGE_INDEX_CACHE = b'index_cache' + BIP32_MAX_PATH_LEVEL = 2**31 + BIP32_EXT_ID = 0 + BIP32_INT_ID = 1 + ENTROPY_BYTES = 16 + + def __init__(self, storage, gap_limit=6, merge_algorithm_name=None): + self._entropy = None + # {mixdepth: {type: index}} with type being 0/1 for [non]-internal + self._index_cache = None + # path is a tuple of BIP32 levels, + # m is the master key's fingerprint + # other levels are ints + super(BIP32Wallet, self).__init__(storage, gap_limit, + merge_algorithm_name) + assert self._index_cache is not None + assert self._verify_entropy(self._entropy) + + _master_entropy = self._create_master_key() + assert _master_entropy + self._master_key = self._derive_bip32_master_key(_master_entropy) + + # used to verify paths for sanity checking and for wallet id creation + self._key_ident = b'' # otherwise get_bip32_* won't work + self._key_ident = sha256(sha256( + self.get_bip32_priv_export(0, 0).encode('ascii')).digest())\ + .digest()[:3] + self._populate_script_map() + + @classmethod + def initialize(cls, storage, network, max_mixdepth=2, timestamp=None, + entropy=None, write=True): + """ + args: + entropy: ENTROPY_BYTES bytes or None to have wallet generate some + """ + if entropy and not cls._verify_entropy(entropy): + raise WalletError("Invalid entropy.") + + super(BIP32Wallet, cls).initialize(storage, network, max_mixdepth, + timestamp, write=False) + + if not entropy: + entropy = get_random_bytes(cls.ENTROPY_BYTES, True) + + storage.data[cls._STORAGE_ENTROPY_KEY] = entropy + storage.data[cls._STORAGE_INDEX_CACHE] = {} + + if write: + storage.save() + + def _load_storage(self): + super(BIP32Wallet, self)._load_storage() + self._entropy = self._storage.data[self._STORAGE_ENTROPY_KEY] + + self._index_cache = collections.defaultdict( + lambda: collections.defaultdict(int)) + + for md, data in self._storage.data[self._STORAGE_INDEX_CACHE].items(): + md = int(md) + md_map = self._index_cache[md] + for t, k in data.items(): + md_map[int(t)] = k + + def _populate_script_map(self): + for md in self._index_cache: + for int_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID): + for i in range(self._index_cache[md][int_type]): + path = self.get_path(md, int_type, i) + script = self.get_script_path(path) + self._script_map[script] = path + + def save(self): + for md, data in self._index_cache.items(): + str_data = {} + str_md = _int_to_bytestr(md) + + for t, k in data.items(): + str_data[_int_to_bytestr(t)] = k + + self._storage.data[self._STORAGE_INDEX_CACHE][str_md] = str_data + + super(BIP32Wallet, self).save() + + def _create_master_key(self): + """ + for base/legacy wallet type, this is a passthrough. + for bip39 style wallets, this will convert from one to the other + """ + return self._entropy + + @classmethod + def _verify_entropy(cls, ent): + # This is not very useful but true for BIP32. Subclasses may have + # stricter requirements. + return bool(ent) + + @classmethod + def _derive_bip32_master_key(cls, seed): + return cls._ENGINE.derive_bip32_master_key(seed) + + def get_script_path(self, path): + if not self._is_my_bip32_path(path): + raise WalletError("unable to get script for unknown key path") + + md, int_type, index = self.get_details(path) + + if not 0 <= md <= self.max_mixdepth: + raise WalletError("Mixdepth outside of wallet's range.") + assert int_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID) + + current_index = self._index_cache[md][int_type] + + if index == current_index: + return self.get_new_script(md, int_type) + + priv, engine = self._get_priv_from_path(path) + script = engine.privkey_to_script(priv) + + return script + + def get_path(self, mixdepth=None, internal=None, index=None): + if mixdepth is not None: + assert isinstance(mixdepth, int) + if not 0 <= mixdepth <= self.max_mixdepth: + raise WalletError("Mixdepth outside of wallet's range.") + + if internal is not None: + if mixdepth is None: + raise Exception("mixdepth must be set if internal is set") + int_type = self._get_internal_type(internal) + + if index is not None: + assert isinstance(index, int) + if internal is None: + raise Exception("internal must be set if index is set") + assert index <= self._index_cache[mixdepth][int_type] + assert index < self.BIP32_MAX_PATH_LEVEL + return tuple(chain(self._get_bip32_export_path(mixdepth, internal), + (index,))) + + return tuple(self._get_bip32_export_path(mixdepth, internal)) + + def get_path_repr(self, path): + path = list(path) + assert self._is_my_bip32_path(path) + path.pop(0) + return 'm' + '/' + '/'.join(map(self._path_level_to_repr, path)) + + @classmethod + def _harden_path_level(cls, lvl): + assert isinstance(lvl, int) + if not 0 <= lvl < cls.BIP32_MAX_PATH_LEVEL: + raise WalletError("Unable to derive hardened path level from {}." + "".format(lvl)) + return lvl + cls.BIP32_MAX_PATH_LEVEL + + @classmethod + def _path_level_to_repr(cls, lvl): + assert isinstance(lvl, int) + if not 0 <= lvl < cls.BIP32_MAX_PATH_LEVEL * 2: + raise WalletError("Invalid path level {}.".format(lvl)) + if lvl < cls.BIP32_MAX_PATH_LEVEL: + return str(lvl) + return str(lvl - cls.BIP32_MAX_PATH_LEVEL) + "'" + + def path_repr_to_path(self, pathstr): + spath = pathstr.split('/') + assert len(spath) > 0 + if spath[0] != 'm': + raise WalletError("Not a valid wallet path: {}".format(pathstr)) + + def conv_level(lvl): + if lvl[-1] == "'": + return self._harden_path_level(int(lvl[:-1])) + return int(lvl) + + return tuple(chain((self._key_ident,), map(conv_level, spath[1:]))) + + def _get_mixdepth_from_path(self, path): + if not self._is_my_bip32_path(path): + raise WalletError("Invalid path, unknown root: {}".format(path)) + + return path[len(self._get_bip32_base_path())] + + def _get_priv_from_path(self, path): + if not self._is_my_bip32_path(path): + raise WalletError("Invalid path, unknown root: {}".format(path)) + + return self._ENGINE.derive_bip32_privkey(self._master_key, path), \ + self._ENGINE + + def _is_my_bip32_path(self, path): + return path[0] == self._key_ident + + def get_new_script(self, mixdepth, internal): + # This is called by get_script_path and calls back there. We need to + # ensure all conditions match to avoid endless recursion. + int_type = self._get_internal_type(internal) + index = self._index_cache[mixdepth][int_type] + self._index_cache[mixdepth][int_type] += 1 + path = self.get_path(mixdepth, int_type, index) + script = self.get_script_path(path) + self._script_map[script] = path + return script + + def get_script(self, mixdepth, internal, index): + path = self.get_path(mixdepth, internal, index) + return self.get_script_path(path) + + @deprecated + def get_key(self, mixdepth, internal, index): + int_type = self._get_internal_type(internal) + path = self.get_path(mixdepth, int_type, index) + priv = self._ENGINE.derive_bip32_privkey(self._master_key, path) + return hexlify(priv) + + def get_bip32_priv_export(self, mixdepth=None, internal=None): + path = self._get_bip32_export_path(mixdepth, internal) + return self._ENGINE.derive_bip32_priv_export(self._master_key, path) + + def get_bip32_pub_export(self, mixdepth=None, internal=None): + path = self._get_bip32_export_path(mixdepth, internal) + return self._ENGINE.derive_bip32_pub_export(self._master_key, path) + + def _get_bip32_export_path(self, mixdepth=None, internal=None): + if mixdepth is None: + assert internal is None + path = tuple() + else: + assert 0 <= mixdepth <= self.max_mixdepth + if internal is None: + path = (self._get_bip32_mixdepth_path_level(mixdepth),) + else: + int_type = self._get_internal_type(internal) + path = (self._get_bip32_mixdepth_path_level(mixdepth), int_type) + + return tuple(chain(self._get_bip32_base_path(), path)) + + def _get_bip32_base_path(self): + return self._key_ident, + + @classmethod + def _get_bip32_mixdepth_path_level(cls, mixdepth): + return mixdepth + + def _get_internal_type(self, is_internal): + return self.BIP32_INT_ID if is_internal else self.BIP32_EXT_ID + + def get_next_unused_index(self, mixdepth, internal): + assert 0 <= mixdepth <= self.max_mixdepth + int_type = self._get_internal_type(internal) + + if self._index_cache[mixdepth][int_type] >= self.BIP32_MAX_PATH_LEVEL: + # FIXME: theoretically this should work for up to + # self.BIP32_MAX_PATH_LEVEL * 2, no? + raise WalletError("All addresses used up, cannot generate new ones.") + + return self._index_cache[mixdepth][int_type] + + def get_mnemonic_words(self): + return ' '.join(mn_encode(hexlify(self._entropy))), None + + @classmethod + def entropy_from_mnemonic(cls, seed): + words = seed.split() + if len(words) != 12: + raise WalletError("Seed phrase must consist of exactly 12 words.") + + return unhexlify(mn_decode(words)) + + def get_wallet_id(self): + return hexlify(self._key_ident) + + def set_next_index(self, mixdepth, internal, index, force=False): + int_type = self._get_internal_type(internal) + if not (force or index <= self._index_cache[mixdepth][int_type]): + raise Exception("cannot advance index without force=True") + self._index_cache[mixdepth][int_type] = index + + def get_details(self, path): + if not self._is_my_bip32_path(path): + raise Exception("path does not belong to wallet") + return self._get_mixdepth_from_path(path), path[-2], path[-1] + + +class LegacyWallet(ImportWalletMixin, BIP32Wallet): + TYPE = TYPE_P2PKH + _ENGINE = BTC_P2PKH + + def _create_master_key(self): + return hexlify(self._entropy).encode('ascii') + + def _get_bip32_base_path(self): + return self._key_ident, 0 + + +class BIP49Wallet(BIP32Wallet): + _BIP49_PURPOSE = 2**31 + 49 + _ENGINE = BTC_P2SH_P2WPKH + + def _get_bip32_base_path(self): + return self._key_ident, self._BIP49_PURPOSE,\ + self._ENGINE.BIP44_COIN_TYPE + + @classmethod + def _get_bip32_mixdepth_path_level(cls, mixdepth): + assert 0 <= mixdepth < 2**31 + return cls._harden_path_level(mixdepth) + + def _get_mixdepth_from_path(self, path): + if not self._is_my_bip32_path(path): + raise WalletError("Invalid path, unknown root: {}".format(path)) + + return path[len(self._get_bip32_base_path())] - 2**31 + + +class SegwitLegacyWallet(ImportWalletMixin, BIP39WalletMixin, BIP49Wallet): + TYPE = TYPE_P2SH_P2WPKH + + +WALLET_IMPLEMENTATIONS = { + LegacyWallet.TYPE: LegacyWallet, + SegwitLegacyWallet.TYPE: SegwitLegacyWallet +} diff --git a/jmclient/test/test_argon2.py b/jmclient/test/test_argon2.py new file mode 100644 index 0000000..7de4b78 --- /dev/null +++ b/jmclient/test/test_argon2.py @@ -0,0 +1,35 @@ +from __future__ import print_function, absolute_import, division, unicode_literals + +from jmclient import Argon2Hash, get_random_bytes +import pytest + + +def test_argon2_sanity(): + pwd = b'password' + salt = b'saltsalt' + + h = Argon2Hash(pwd, salt, 16) + + assert len(h.hash) == 16 + assert h.salt == salt + assert h.hash == b'\x05;V\xd7fy\xdfI\xa4\xe7F$_\\3\xcb' + + +def test_get_random_bytes(): + assert len(get_random_bytes(16)) == 16 + assert get_random_bytes(16) != get_random_bytes(16) + + +def test_argon2(): + pwd = b'testpass' + h = Argon2Hash(pwd, hash_len=16, salt_len=22) + + assert len(h.hash) == 16 + assert len(h.salt) == 22 + + h2 = Argon2Hash(pwd, h.salt, hash_len=16) + + assert h.settings == h2.settings + assert h.hash == h2.hash + assert h.salt == h2.salt + diff --git a/jmclient/test/test_blockchaininterface.py b/jmclient/test/test_blockchaininterface.py new file mode 100644 index 0000000..58fe400 --- /dev/null +++ b/jmclient/test/test_blockchaininterface.py @@ -0,0 +1,149 @@ +from __future__ import absolute_import, print_function + +"""Blockchaininterface functionality tests.""" + +import binascii +from commontest import create_wallet_for_sync, make_sign_and_push + +import pytest +from jmclient import load_program_config, jm_single, sync_wallet, get_log + +log = get_log() + + +def sync_test_wallet(fast, wallet): + sync_count = 0 + jm_single().bc_interface.wallet_synced = False + while not jm_single().bc_interface.wallet_synced: + sync_wallet(wallet, fast=fast) + sync_count += 1 + # avoid infinite loop + assert sync_count < 10 + log.debug("Tried " + str(sync_count) + " times") + + +@pytest.mark.parametrize('fast', (False, True)) +def test_empty_wallet_sync(setup_wallets, fast): + wallet = create_wallet_for_sync([0, 0, 0, 0, 0], ['test_empty_wallet_sync']) + + sync_test_wallet(fast, wallet) + + broken = True + for md in range(wallet.max_mixdepth + 1): + for internal in (True, False): + broken = False + assert 0 == wallet.get_next_unused_index(md, internal) + assert not broken + + +@pytest.mark.parametrize('fast,internal', ( + (False, False), (False, True), + (True, False), (True, True))) +def test_sequentially_used_wallet_sync(setup_wallets, fast, internal): + used_count = [1, 3, 6, 2, 23] + wallet = create_wallet_for_sync( + used_count, ['test_sequentially_used_wallet_sync'], + populate_internal=internal) + + sync_test_wallet(fast, wallet) + + broken = True + for md in range(len(used_count)): + broken = False + assert used_count[md] == wallet.get_next_unused_index(md, internal) + assert not broken + + +@pytest.mark.parametrize('fast', (False, True)) +def test_gap_used_wallet_sync(setup_wallets, fast): + used_count = [1, 3, 6, 2, 23] + wallet = create_wallet_for_sync(used_count, ['test_gap_used_wallet_sync']) + wallet.gap_limit = 20 + + for md in range(len(used_count)): + x = -1 + for x in range(md): + assert x <= wallet.gap_limit, "test broken" + # create some unused addresses + wallet.get_new_script(md, True) + wallet.get_new_script(md, False) + used_count[md] += x + 2 + jm_single().bc_interface.grab_coins(wallet.get_new_addr(md, True), 1) + jm_single().bc_interface.grab_coins(wallet.get_new_addr(md, False), 1) + + # reset indices to simulate completely unsynced wallet + for md in range(wallet.max_mixdepth + 1): + wallet.set_next_index(md, True, 0) + wallet.set_next_index(md, False, 0) + + sync_test_wallet(fast, wallet) + + broken = True + for md in range(len(used_count)): + broken = False + assert md + 1 == wallet.get_next_unused_index(md, True) + assert used_count[md] == wallet.get_next_unused_index(md, False) + assert not broken + + +@pytest.mark.parametrize('fast', (False, True)) +def test_multigap_used_wallet_sync(setup_wallets, fast): + start_index = 5 + used_count = [start_index, 0, 0, 0, 0] + wallet = create_wallet_for_sync(used_count, ['test_multigap_used_wallet_sync']) + wallet.gap_limit = 5 + + mixdepth = 0 + for w in range(5): + for x in range(int(wallet.gap_limit * 0.6)): + assert x <= wallet.gap_limit, "test broken" + # create some unused addresses + wallet.get_new_script(mixdepth, True) + wallet.get_new_script(mixdepth, False) + used_count[mixdepth] += x + 2 + jm_single().bc_interface.grab_coins(wallet.get_new_addr(mixdepth, True), 1) + jm_single().bc_interface.grab_coins(wallet.get_new_addr(mixdepth, False), 1) + + # reset indices to simulate completely unsynced wallet + for md in range(wallet.max_mixdepth + 1): + wallet.set_next_index(md, True, 0) + wallet.set_next_index(md, False, 0) + + sync_test_wallet(fast, wallet) + + assert used_count[mixdepth] - start_index == wallet.get_next_unused_index(mixdepth, True) + assert used_count[mixdepth] == wallet.get_next_unused_index(mixdepth, False) + + +@pytest.mark.parametrize('fast', (False, True)) +def test_retain_unused_indices_wallet_sync(setup_wallets, fast): + used_count = [0, 0, 0, 0, 0] + wallet = create_wallet_for_sync(used_count, ['test_retain_unused_indices_wallet_sync']) + + for x in range(9): + wallet.get_new_script(0, 1) + + sync_test_wallet(fast, wallet) + + assert wallet.get_next_unused_index(0, 1) == 9 + + +@pytest.mark.parametrize('fast', (False, True)) +def test_imported_wallet_sync(setup_wallets, fast): + used_count = [0, 0, 0, 0, 0] + wallet = create_wallet_for_sync(used_count, ['test_imported_wallet_sync']) + source_wallet = create_wallet_for_sync(used_count, ['test_imported_wallet_sync_origin']) + + address = source_wallet.get_new_addr(0, 1) + wallet.import_private_key(0, source_wallet.get_wif(0, 1, 0)) + txid = binascii.unhexlify(jm_single().bc_interface.grab_coins(address, 1)) + + sync_test_wallet(fast, wallet) + + assert wallet._utxos.have_utxo(txid, 0) == 0 + + +@pytest.fixture(scope='module') +def setup_wallets(): + load_program_config() + jm_single().bc_interface.tick_forward_chain_interval = 1 diff --git a/jmclient/test/test_coinjoin.py b/jmclient/test/test_coinjoin.py index 3137048..5498fac 100644 --- a/jmclient/test/test_coinjoin.py +++ b/jmclient/test/test_coinjoin.py @@ -10,10 +10,11 @@ import pytest from twisted.internet import reactor from jmclient import load_program_config, jm_single, get_log,\ - YieldGeneratorBasic, Taker, sync_wallet + YieldGeneratorBasic, Taker, sync_wallet, LegacyWallet, SegwitLegacyWallet from jmclient.podle import set_commitment_file -from commontest import make_wallets +from commontest import make_wallets, binarize_tx from test_taker import dummy_filter_orderbook +import jmbitcoin as btc testdir = os.path.dirname(os.path.realpath(__file__)) log = get_log() @@ -51,8 +52,11 @@ def create_orderbook(makers): def create_taker(wallet, schedule, monkeypatch): def on_finished_callback(*args, **kwargs): + log.debug("on finished called with: {}, {}".format(args, kwargs)) + on_finished_callback.status = args[0] on_finished_callback.called = True on_finished_callback.called = False + on_finished_callback.status = None taker = Taker(wallet, schedule, callbacks=(dummy_filter_orderbook, None, on_finished_callback)) @@ -104,7 +108,8 @@ def do_tx_signing(taker, makers, active_orders, txdata): return taker_final_result -def test_simple_coinjoin(monkeypatch, tmpdir, setup_cj): +@pytest.mark.parametrize('wallet_cls', (LegacyWallet, SegwitLegacyWallet)) +def test_simple_coinjoin(monkeypatch, tmpdir, setup_cj, wallet_cls): def raise_exit(i): raise Exception("sys.exit called") monkeypatch.setattr(sys, 'exit', raise_exit) @@ -113,7 +118,7 @@ def test_simple_coinjoin(monkeypatch, tmpdir, setup_cj): MAKER_NUM = 3 wallets = make_wallets_to_list(make_wallets( MAKER_NUM + 1, wallet_structures=[[4, 0, 0, 0, 0]] * (MAKER_NUM + 1), - mean_amt=1)) + mean_amt=1, wallet_cls=wallet_cls)) jm_single().bc_interface.tickchain() sync_wallets(wallets) @@ -138,6 +143,115 @@ def test_simple_coinjoin(monkeypatch, tmpdir, setup_cj): taker_final_result = do_tx_signing(taker, makers, active_orders, txdata) assert taker_final_result is not False + assert taker.on_finished_callback.status is not False + + +def test_coinjoin_mixdepth_wrap_taker(monkeypatch, tmpdir, setup_cj): + def raise_exit(i): + raise Exception("sys.exit called") + monkeypatch.setattr(sys, 'exit', raise_exit) + set_commitment_file(str(tmpdir.join('commitments.json'))) + + MAKER_NUM = 3 + wallets = make_wallets_to_list(make_wallets( + MAKER_NUM + 1, + wallet_structures=[[4, 0, 0, 0, 0]] * MAKER_NUM + [[0, 0, 0, 0, 3]], + mean_amt=1)) + + for w in wallets: + assert w.max_mixdepth == 4 + + jm_single().bc_interface.tickchain() + jm_single().bc_interface.tickchain() + sync_wallets(wallets) + + cj_fee = 2000 + makers = [YieldGeneratorBasic( + wallets[i], + [0, cj_fee, 0, 'swabsoffer', 10**7]) for i in range(MAKER_NUM)] + + orderbook = create_orderbook(makers) + assert len(orderbook) == MAKER_NUM + + cj_amount = int(1.1 * 10**8) + # mixdepth, amount, counterparties, dest_addr, waittime + schedule = [(4, cj_amount, MAKER_NUM, 'INTERNAL', 0)] + taker = create_taker(wallets[-1], schedule, monkeypatch) + + active_orders, maker_data = init_coinjoin(taker, makers, + orderbook, cj_amount) + + txdata = taker.receive_utxos(maker_data) + assert txdata[0], "taker.receive_utxos error" + + taker_final_result = do_tx_signing(taker, makers, active_orders, txdata) + assert taker_final_result is not False + + tx = btc.deserialize(txdata[2]) + binarize_tx(tx) + + w = wallets[-1] + w.remove_old_utxos_(tx) + w.add_new_utxos_(tx, b'\x00' * 32) # fake txid + + balances = w.get_balance_by_mixdepth() + assert balances[0] == cj_amount + # <= because of tx fee + assert balances[4] <= 3 * 10**8 - cj_amount - (cj_fee * MAKER_NUM) + + +def test_coinjoin_mixdepth_wrap_maker(monkeypatch, tmpdir, setup_cj): + def raise_exit(i): + raise Exception("sys.exit called") + monkeypatch.setattr(sys, 'exit', raise_exit) + set_commitment_file(str(tmpdir.join('commitments.json'))) + + MAKER_NUM = 2 + wallets = make_wallets_to_list(make_wallets( + MAKER_NUM + 1, + wallet_structures=[[0, 0, 0, 0, 4]] * MAKER_NUM + [[3, 0, 0, 0, 0]], + mean_amt=1)) + + for w in wallets: + assert w.max_mixdepth == 4 + + jm_single().bc_interface.tickchain() + jm_single().bc_interface.tickchain() + sync_wallets(wallets) + + cj_fee = 2000 + makers = [YieldGeneratorBasic( + wallets[i], + [0, cj_fee, 0, 'swabsoffer', 10**7]) for i in range(MAKER_NUM)] + + orderbook = create_orderbook(makers) + assert len(orderbook) == MAKER_NUM + + cj_amount = int(1.1 * 10**8) + # mixdepth, amount, counterparties, dest_addr, waittime + schedule = [(0, cj_amount, MAKER_NUM, 'INTERNAL', 0)] + taker = create_taker(wallets[-1], schedule, monkeypatch) + + active_orders, maker_data = init_coinjoin(taker, makers, + orderbook, cj_amount) + + txdata = taker.receive_utxos(maker_data) + assert txdata[0], "taker.receive_utxos error" + + taker_final_result = do_tx_signing(taker, makers, active_orders, txdata) + assert taker_final_result is not False + + tx = btc.deserialize(txdata[2]) + binarize_tx(tx) + + for i in range(MAKER_NUM): + w = wallets[i] + w.remove_old_utxos_(tx) + w.add_new_utxos_(tx, b'\x00' * 32) # fake txid + + balances = w.get_balance_by_mixdepth() + assert balances[0] == cj_amount + assert balances[4] == 4 * 10**8 - cj_amount + cj_fee @pytest.fixture(scope='module') diff --git a/jmclient/test/test_storage.py b/jmclient/test/test_storage.py new file mode 100644 index 0000000..1c54304 --- /dev/null +++ b/jmclient/test/test_storage.py @@ -0,0 +1,128 @@ +from __future__ import print_function, absolute_import, division, unicode_literals + +from jmclient import storage +import pytest + + +class MockStorage(storage.Storage): + def __init__(self, data, *args, **kwargs): + self.file_data = data + self.locked = False + super(type(self), self).__init__(*args, **kwargs) + + def _read_file(self): + if hasattr(self, 'file_data'): + return self.file_data + return b'' + + def _write_file(self, data): + self.file_data = data + + def _create_lock(self): + self.locked = not self.read_only + + def _remove_lock(self): + self.locked = False + + +def test_storage(): + s = MockStorage(None, 'nonexistant', b'password', create=True) + assert s.file_data.startswith(s.MAGIC_ENC) + assert s.locked + assert s.is_encrypted() + assert not s.was_changed() + + old_data = s.file_data + + s.data[b'mydata'] = b'test' + assert s.was_changed() + s.save() + assert s.file_data != old_data + enc_data = s.file_data + + old_data = s.file_data + s.change_password(b'newpass') + assert s.is_encrypted() + assert not s.was_changed() + assert s.file_data != old_data + + old_data = s.file_data + s.change_password(None) + assert not s.is_encrypted() + assert not s.was_changed() + assert s.file_data != old_data + assert s.file_data.startswith(s.MAGIC_UNENC) + + s2 = MockStorage(enc_data, __file__, b'password') + assert s2.locked + assert s2.is_encrypted() + assert not s2.was_changed() + assert s2.data[b'mydata'] == b'test' + + +def test_storage_invalid(): + with pytest.raises(storage.StorageError, message="File does not exist"): + MockStorage(None, 'nonexistant', b'password') + + s = MockStorage(None, 'nonexistant', b'password', create=True) + with pytest.raises(storage.StorageError, message="Wrong password"): + MockStorage(s.file_data, __file__, b'wrongpass') + + with pytest.raises(storage.StorageError, message="No password"): + MockStorage(s.file_data, __file__) + + with pytest.raises(storage.StorageError, message="Non-wallet file, unencrypted"): + MockStorage(b'garbagefile', __file__) + + with pytest.raises(storage.StorageError, message="Non-wallet file, encrypted"): + MockStorage(b'garbagefile', __file__, b'password') + + +def test_storage_readonly(): + s = MockStorage(None, 'nonexistant', b'password', create=True) + s = MockStorage(s.file_data, __file__, b'password', read_only=True) + s.data[b'mydata'] = b'test' + + assert not s.locked + assert s.was_changed() + + with pytest.raises(storage.StorageError): + s.save() + + with pytest.raises(storage.StorageError): + s.change_password(b'newpass') + + +def test_storage_lock(tmpdir): + p = str(tmpdir.join('test.jmdat')) + pw = None + + with pytest.raises(storage.StorageError, message="File does not exist"): + storage.Storage(p, pw) + + s = storage.Storage(p, pw, create=True) + assert s.is_locked() + assert not s.is_encrypted() + assert s.data == {} + + with pytest.raises(storage.StorageError, message="File is locked"): + storage.Storage(p, pw) + + assert storage.Storage.is_storage_file(p) + assert not storage.Storage.is_encrypted_storage_file(p) + + s.data[b'test'] = b'value' + s.save() + s.close() + del s + + s = storage.Storage(p, pw, read_only=True) + assert not s.is_locked() + assert s.data == {b'test': b'value'} + s.close() + del s + + s = storage.Storage(p, pw) + assert s.is_locked() + assert s.data == {b'test': b'value'} + diff --git a/jmclient/test/test_utxomanager.py b/jmclient/test/test_utxomanager.py new file mode 100644 index 0000000..2c64e8e --- /dev/null +++ b/jmclient/test/test_utxomanager.py @@ -0,0 +1,93 @@ +from __future__ import print_function, absolute_import, division, unicode_literals + +from jmclient.wallet import UTXOManager +from test_storage import MockStorage +import pytest + +from jmclient import load_program_config +import jmclient +from commontest import DummyBlockchainInterface + + +def select(unspent, value): + return unspent + + +def test_utxomanager_persist(setup_env_nodeps): + storage = MockStorage(None, 'wallet.jmdat', None, create=True) + UTXOManager.initialize(storage) + um = UTXOManager(storage, select) + + txid = b'\x00' * UTXOManager.TXID_LEN + index = 0 + path = (0,) + mixdepth = 0 + value = 500 + + um.add_utxo(txid, index, path, value, mixdepth) + um.add_utxo(txid, index+1, path, value, mixdepth+1) + + um.save() + del um + + um = UTXOManager(storage, select) + + assert um.have_utxo(txid, index) is mixdepth + assert um.have_utxo(txid, index+1) is mixdepth + 1 + assert um.have_utxo(txid, index+2) is False + + utxos = um.get_utxos_by_mixdepth() + assert len(utxos[mixdepth]) == 1 + assert len(utxos[mixdepth+1]) == 1 + assert len(utxos[mixdepth+2]) == 0 + + balances = um.get_balance_by_mixdepth() + assert balances[mixdepth] == value + assert balances[mixdepth+1] == value + + um.remove_utxo(txid, index, mixdepth) + assert um.have_utxo(txid, index) is False + + um.save() + del um + + um = UTXOManager(storage, select) + + assert um.have_utxo(txid, index) is False + assert um.have_utxo(txid, index+1) is mixdepth + 1 + + utxos = um.get_utxos_by_mixdepth() + assert len(utxos[mixdepth]) == 0 + assert len(utxos[mixdepth+1]) == 1 + + balances = um.get_balance_by_mixdepth() + assert balances[mixdepth] == 0 + assert balances[mixdepth+1] == value + assert balances[mixdepth+2] == 0 + + +def test_utxomanager_select(setup_env_nodeps): + storage = MockStorage(None, 'wallet.jmdat', None, create=True) + UTXOManager.initialize(storage) + um = UTXOManager(storage, select) + + txid = b'\x00' * UTXOManager.TXID_LEN + index = 0 + path = (0,) + mixdepth = 0 + value = 500 + + um.add_utxo(txid, index, path, value, mixdepth) + + assert len(um.select_utxos(mixdepth, value)) is 1 + assert len(um.select_utxos(mixdepth+1, value)) is 0 + + um.add_utxo(txid, index+1, path, value, mixdepth) + assert len(um.select_utxos(mixdepth, value)) is 2 + + +@pytest.fixture +def setup_env_nodeps(monkeypatch): + monkeypatch.setattr(jmclient.configure, 'get_blockchain_interface_instance', + lambda x: DummyBlockchainInterface()) + load_program_config() diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py new file mode 100644 index 0000000..7031101 --- /dev/null +++ b/jmclient/test/test_wallet.py @@ -0,0 +1,590 @@ +from __future__ import print_function, absolute_import, division, unicode_literals +'''Wallet functionality tests.''' + +import os +import json +from binascii import hexlify, unhexlify + +import pytest +import jmbitcoin as btc +from commontest import binarize_tx +from jmclient import load_program_config, jm_single, get_log,\ + SegwitLegacyWallet,BIP32Wallet, BIP49Wallet, LegacyWallet,\ + VolatileStorage, get_network, cryptoengine, WalletError + +testdir = os.path.dirname(os.path.realpath(__file__)) +log = get_log() + + +def signed_tx_is_segwit(tx): + for inp in tx['ins']: + if 'txinwitness' not in inp: + return False + return True + + +def assert_segwit(tx): + assert signed_tx_is_segwit(tx) + + +def assert_not_segwit(tx): + assert not signed_tx_is_segwit(tx) + + +def get_populated_wallet(amount=10**8, num=3): + storage = VolatileStorage() + SegwitLegacyWallet.initialize(storage, get_network()) + wallet = SegwitLegacyWallet(storage) + + # fund three wallet addresses at mixdepth 0 + for i in range(num): + fund_wallet_addr(wallet, wallet.get_internal_addr(0), amount / 10**8) + + return wallet + + +def fund_wallet_addr(wallet, addr, value_btc=1): + txin_id = jm_single().bc_interface.grab_coins(addr, value_btc) + txinfo = jm_single().bc_interface.rpc('gettransaction', [txin_id]) + txin = btc.deserialize(unhexlify(txinfo['hex'])) + utxo_in = wallet.add_new_utxos_(txin, unhexlify(txin_id)) + assert len(utxo_in) == 1 + return list(utxo_in.keys())[0] + + +def get_bip39_vectors(): + fh = open(os.path.join(testdir, 'bip39vectors.json')) + data = json.load(fh)['english'] + fh.close() + return data + + +@pytest.mark.parametrize('entropy,mnemonic,key,xpriv', get_bip39_vectors()) +def test_bip39_seeds(monkeypatch, setup_wallet, entropy, mnemonic, key, xpriv): + jm_single().config.set('BLOCKCHAIN', 'network', 'mainnet') + created_entropy = SegwitLegacyWallet.entropy_from_mnemonic(mnemonic) + assert entropy == hexlify(created_entropy) + storage = VolatileStorage() + SegwitLegacyWallet.initialize( + storage, get_network(), entropy=created_entropy, + entropy_extension=b'TREZOR', max_mixdepth=4) + wallet = SegwitLegacyWallet(storage) + assert (mnemonic, b'TREZOR') == wallet.get_mnemonic_words() + assert key == hexlify(wallet._create_master_key()) + + # need to monkeypatch this, else we'll default to the BIP-49 path + monkeypatch.setattr(SegwitLegacyWallet, '_get_bip32_base_path', + BIP32Wallet._get_bip32_base_path) + assert xpriv == wallet.get_bip32_priv_export() + + +def test_bip49_seed(monkeypatch, setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + master_xpriv = 'tprv8ZgxMBicQKsPe5YMU9gHen4Ez3ApihUfykaqUorj9t6FDqy3nP6eoXiAo2ssvpAjoLroQxHqr3R5nE3a5dU3DHTjTgJDd7zrbniJr6nrCzd' + account0_xpriv = 'tprv8gRrNu65W2Msef2BdBSUgFdRTGzC8EwVXnV7UGS3faeXtuMVtGfEdidVeGbThs4ELEoayCAzZQ4uUji9DUiAs7erdVskqju7hrBcDvDsdbY' + addr0_script_hash = '336caa13e08b96080a32b5d818d59b4ab3b36742' + + entropy = SegwitLegacyWallet.entropy_from_mnemonic(mnemonic) + storage = VolatileStorage() + SegwitLegacyWallet.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=0) + wallet = SegwitLegacyWallet(storage) + assert (mnemonic, None) == wallet.get_mnemonic_words() + assert account0_xpriv == wallet.get_bip32_priv_export(0) + assert addr0_script_hash == hexlify(wallet.get_external_script(0)[2:-1]) + + # FIXME: is this desired behaviour? BIP49 wallet will not return xpriv for + # the root key but only for key after base path + monkeypatch.setattr(SegwitLegacyWallet, '_get_bip32_base_path', + BIP32Wallet._get_bip32_base_path) + assert master_xpriv == wallet.get_bip32_priv_export() + + +def test_bip32_test_vector_1(monkeypatch, setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'mainnet') + + entropy = unhexlify('000102030405060708090a0b0c0d0e0f') + storage = VolatileStorage() + LegacyWallet.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=0) + + # test vector 1 is using hardened derivation for the account/mixdepth level + monkeypatch.setattr(LegacyWallet, '_get_mixdepth_from_path', + BIP49Wallet._get_mixdepth_from_path) + monkeypatch.setattr(LegacyWallet, '_get_bip32_mixdepth_path_level', + BIP49Wallet._get_bip32_mixdepth_path_level) + monkeypatch.setattr(LegacyWallet, '_get_bip32_base_path', + BIP32Wallet._get_bip32_base_path) + monkeypatch.setattr(LegacyWallet, '_create_master_key', + BIP32Wallet._create_master_key) + + wallet = LegacyWallet(storage) + + assert wallet.get_bip32_priv_export() == 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + assert wallet.get_bip32_pub_export() == 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8' + assert wallet.get_bip32_priv_export(0) == 'xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7' + assert wallet.get_bip32_pub_export(0) == 'xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw' + assert wallet.get_bip32_priv_export(0, 1) == 'xprv9wTYmMFdV23N2TdNG573QoEsfRrWKQgWeibmLntzniatZvR9BmLnvSxqu53Kw1UmYPxLgboyZQaXwTCg8MSY3H2EU4pWcQDnRnrVA1xe8fs' + assert wallet.get_bip32_pub_export(0, 1) == 'xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ' + # there are more test vectors but those don't match joinmarket's wallet + # structure, hence they make litte sense to test here + + +def test_bip32_test_vector_2(monkeypatch, setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'mainnet') + + entropy = unhexlify('fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542') + storage = VolatileStorage() + LegacyWallet.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=0) + + monkeypatch.setattr(LegacyWallet, '_get_bip32_base_path', + BIP32Wallet._get_bip32_base_path) + monkeypatch.setattr(LegacyWallet, '_create_master_key', + BIP32Wallet._create_master_key) + + wallet = LegacyWallet(storage) + + assert wallet.get_bip32_priv_export() == 'xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U' + assert wallet.get_bip32_pub_export() == 'xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB' + assert wallet.get_bip32_priv_export(0) == 'xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt' + assert wallet.get_bip32_pub_export(0) == 'xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH' + # there are more test vectors but those don't match joinmarket's wallet + # structure, hence they make litte sense to test here + + +def test_bip32_test_vector_3(monkeypatch, setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'mainnet') + + entropy = unhexlify('4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be') + storage = VolatileStorage() + LegacyWallet.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=0) + + # test vector 3 is using hardened derivation for the account/mixdepth level + monkeypatch.setattr(LegacyWallet, '_get_mixdepth_from_path', + BIP49Wallet._get_mixdepth_from_path) + monkeypatch.setattr(LegacyWallet, '_get_bip32_mixdepth_path_level', + BIP49Wallet._get_bip32_mixdepth_path_level) + monkeypatch.setattr(LegacyWallet, '_get_bip32_base_path', + BIP32Wallet._get_bip32_base_path) + monkeypatch.setattr(LegacyWallet, '_create_master_key', + BIP32Wallet._create_master_key) + + wallet = LegacyWallet(storage) + + assert wallet.get_bip32_priv_export() == 'xprv9s21ZrQH143K25QhxbucbDDuQ4naNntJRi4KUfWT7xo4EKsHt2QJDu7KXp1A3u7Bi1j8ph3EGsZ9Xvz9dGuVrtHHs7pXeTzjuxBrCmmhgC6' + assert wallet.get_bip32_pub_export() == 'xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13' + assert wallet.get_bip32_priv_export(0) == 'xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L' + assert wallet.get_bip32_pub_export(0) == 'xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y' + + +@pytest.mark.parametrize('mixdepth,internal,index,address,wif', [ + [0, 0, 0, 'mpCX9EbdXpcrKMtjEe1fqFhvzctkfzMYTX', 'cVqtSSoVxFyPqTRGfeESi31uCYfgTF4tGWRtGeVs84fzybiX5TPk'], + [0, 0, 5, 'mtj85a3pFppRhrxNcFig1k7ECshrZjJ9XC', 'cMsFXc4TRw9PTcCTv7x9mr88rDeGXBTLEV67mKaw2cxCkjkhL32G'], + [0, 1, 3, 'n1EaQuqvTRm719hsSJ7yRsj49JfoG1C86q', 'cUgSTqnAtvYoQRXCYy4wCFfaks2Zrz1d55m6mVhFyVhQbkDi7JGJ'], + [2, 1, 2, 'mfxkBk7uDhmF5PJGS9d1NonGiAxPwJqQP4', 'cPcZXSiXPuS5eiT4oDrDKi1mFumw5D1RcWzK2gkGdEHjEz99eyXn'] +]) +def test_bip32_addresses_p2pkh(monkeypatch, setup_wallet, mixdepth, internal, index, address, wif): + """ + Test with a random but fixed entropy + """ + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + LegacyWallet.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=3) + + monkeypatch.setattr(LegacyWallet, '_get_bip32_base_path', + BIP32Wallet._get_bip32_base_path) + monkeypatch.setattr(LegacyWallet, '_create_master_key', + BIP32Wallet._create_master_key) + + wallet = LegacyWallet(storage) + + # wallet needs to know about all intermediate keys + for i in range(index + 1): + wallet.get_new_script(mixdepth, internal) + + assert wif == wallet.get_wif(mixdepth, internal, index) + assert address == wallet.get_addr(mixdepth, internal, index) + + +@pytest.mark.parametrize('mixdepth,internal,index,address,wif', [ + [0, 0, 0, '2MzY5yyonUY7zpHspg7jB7WQs1uJxKafQe4', 'cRAGLvPmhpzJNgdMT4W2gVwEW3fusfaDqdQWM2vnWLgXKzCWKtcM'], + [0, 0, 5, '2MsKvqPGStp3yXT8UivuAaGwfPzT7xYwSWk', 'cSo3h7nRuV4fwhVPXeTDJx6cBCkjAzS9VM8APXViyjoSaMq85ZKn'], + [0, 1, 3, '2N7k6wiQqkuMaApwGhk3HKrifprUSDydqUv', 'cTwq3UsZa8STVmwZR94dDphgqgdLFeuaRFD1Ea44qjbjFfKEb1n5'], + [2, 1, 2, '2MtE6gzHgmEXeWzKsmCJFEqkrpNuBDvoRnz', 'cPV8FZuCvrRpk4RhmhpjnSucHhaQZUan4Vbyo1NVQtuAxurW9grb'] +]) +def test_bip32_addresses_p2sh_p2wpkh(setup_wallet, mixdepth, internal, index, address, wif): + """ + Test with a random but fixed entropy + """ + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWallet.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=3) + wallet = SegwitLegacyWallet(storage) + + # wallet needs to know about all intermediate keys + for i in range(index + 1): + wallet.get_new_script(mixdepth, internal) + + assert wif == wallet.get_wif(mixdepth, internal, index) + assert address == wallet.get_addr(mixdepth, internal, index) + + +def test_import_key(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + SegwitLegacyWallet.initialize(storage, get_network()) + wallet = SegwitLegacyWallet(storage) + + wallet.import_private_key( + 0, 'cRAGLvPmhpzJNgdMT4W2gVwEW3fusfaDqdQWM2vnWLgXKzCWKtcM', + cryptoengine.TYPE_P2SH_P2WPKH) + wallet.import_private_key( + 1, 'cVqtSSoVxFyPqTRGfeESi31uCYfgTF4tGWRtGeVs84fzybiX5TPk', + cryptoengine.TYPE_P2PKH) + + with pytest.raises(WalletError): + wallet.import_private_key( + 1, 'cRAGLvPmhpzJNgdMT4W2gVwEW3fusfaDqdQWM2vnWLgXKzCWKtcM', + cryptoengine.TYPE_P2SH_P2WPKH) + + # test persist imported keys + wallet.save() + data = storage.file_data + + del wallet + del storage + + storage = VolatileStorage(data=data) + wallet = SegwitLegacyWallet(storage) + + imported_paths_md0 = list(wallet.yield_imported_paths(0)) + imported_paths_md1 = list(wallet.yield_imported_paths(1)) + assert len(imported_paths_md0) == 1 + assert len(imported_paths_md1) == 1 + + # verify imported addresses + assert wallet.get_addr_path(imported_paths_md0[0]) == '2MzY5yyonUY7zpHspg7jB7WQs1uJxKafQe4' + assert wallet.get_addr_path(imported_paths_md1[0]) == 'mpCX9EbdXpcrKMtjEe1fqFhvzctkfzMYTX' + + # test remove key + wallet.remove_imported_key(path=imported_paths_md0[0]) + assert not list(wallet.yield_imported_paths(0)) + + assert wallet.get_details(imported_paths_md1[0]) == (1, 'imported', 0) + + +@pytest.mark.parametrize('wif,keytype,type_check', [ + ['cVqtSSoVxFyPqTRGfeESi31uCYfgTF4tGWRtGeVs84fzybiX5TPk', + cryptoengine.TYPE_P2PKH, assert_not_segwit], + ['cRAGLvPmhpzJNgdMT4W2gVwEW3fusfaDqdQWM2vnWLgXKzCWKtcM', + cryptoengine.TYPE_P2SH_P2WPKH, assert_segwit] +]) +def test_signing_imported(setup_wallet, wif, keytype, type_check): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + SegwitLegacyWallet.initialize(storage, get_network()) + wallet = SegwitLegacyWallet(storage) + + MIXDEPTH = 0 + path = wallet.import_private_key(MIXDEPTH, wif, keytype) + utxo = fund_wallet_addr(wallet, wallet.get_addr_path(path)) + tx = btc.deserialize(btc.mktx(['{}:{}'.format(hexlify(utxo[0]), utxo[1])], + ['00'*17 + ':' + str(10**8 - 9000)])) + binarize_tx(tx) + script = wallet.get_script_path(path) + wallet.sign_tx(tx, {0: (script, 10**8)}) + type_check(tx) + txout = jm_single().bc_interface.pushtx(hexlify(btc.serialize(tx))) + assert txout + + +@pytest.mark.parametrize('wallet_cls,type_check', [ + [LegacyWallet, assert_not_segwit], + [SegwitLegacyWallet, assert_segwit] +]) +def test_signing_simple(setup_wallet, wallet_cls, type_check): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + wallet_cls.initialize(storage, get_network()) + wallet = wallet_cls(storage) + utxo = fund_wallet_addr(wallet, wallet.get_internal_addr(0)) + tx = btc.deserialize(btc.mktx(['{}:{}'.format(hexlify(utxo[0]), utxo[1])], + ['00'*17 + ':' + str(10**8 - 9000)])) + binarize_tx(tx) + script = wallet.get_script(0, 1, 0) + wallet.sign_tx(tx, {0: (script, 10**8)}) + type_check(tx) + txout = jm_single().bc_interface.pushtx(hexlify(btc.serialize(tx))) + assert txout + + +def test_add_utxos(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + amount = 10**8 + num_tx = 3 + + wallet = get_populated_wallet(amount, num_tx) + + balances = wallet.get_balance_by_mixdepth() + assert balances[0] == num_tx * amount + for md in range(1, wallet.max_mixdepth + 1): + assert balances[md] == 0 + + utxos = wallet.get_utxos_by_mixdepth_() + assert len(utxos[0]) == num_tx + for md in range(1, wallet.max_mixdepth + 1): + assert not utxos[md] + + with pytest.raises(Exception): + # no funds in mixdepth + wallet.select_utxos_(1, amount) + + with pytest.raises(Exception): + # not enough funds + wallet.select_utxos_(0, amount * (num_tx + 1)) + + wallet.reset_utxos() + assert wallet.get_balance_by_mixdepth()[0] == 0 + + +def test_select_utxos(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + amount = 10**8 + + wallet = get_populated_wallet(amount) + utxos = wallet.select_utxos_(0, amount // 2) + + assert len(utxos) == 1 + utxos = list(utxos.keys()) + + more_utxos = wallet.select_utxos_(0, int(amount * 1.5), utxo_filter=utxos) + assert len(more_utxos) == 2 + assert utxos[0] not in more_utxos + + +def test_add_new_utxos(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + wallet = get_populated_wallet(num=1) + + scripts = [wallet.get_new_script(x, True) for x in range(3)] + tx_scripts = list(scripts) + tx_scripts.append(b'\x22'*17) + + tx = btc.deserialize(btc.mktx( + ['0'*64 + ':2'], [{'script': hexlify(s), 'value': 10**8} + for s in tx_scripts])) + binarize_tx(tx) + txid = b'\x01' * 32 + added = wallet.add_new_utxos_(tx, txid) + assert len(added) == len(scripts) + + added_scripts = {x['script'] for x in added.values()} + for s in scripts: + assert s in added_scripts + + balances = wallet.get_balance_by_mixdepth() + assert balances[0] == 2 * 10**8 + assert balances[1] == 10**8 + assert balances[2] == 10**8 + assert len(balances) == wallet.max_mixdepth + 1 + + +def test_remove_old_utxos(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + wallet = get_populated_wallet() + + # add some more utxos to mixdepth 1 + for i in range(3): + txin = jm_single().bc_interface.grab_coins( + wallet.get_internal_addr(1), 1) + wallet.add_utxo(unhexlify(txin), 0, wallet.get_script(1, 1, i), 10**8) + + inputs = wallet.select_utxos_(0, 10**8) + inputs.update(wallet.select_utxos_(1, 2 * 10**8)) + assert len(inputs) == 3 + + tx_inputs = list(inputs.keys()) + tx_inputs.append((b'\x12'*32, 6)) + + tx = btc.deserialize(btc.mktx( + ['{}:{}'.format(hexlify(txid), i) for txid, i in tx_inputs], + ['0' * 36 + ':' + str(3 * 10**8 - 1000)])) + binarize_tx(tx) + + removed = wallet.remove_old_utxos_(tx) + assert len(removed) == len(inputs) + + for txid in removed: + assert txid in inputs + + balances = wallet.get_balance_by_mixdepth() + assert balances[0] == 2 * 10**8 + assert balances[1] == 10**8 + assert balances[2] == 0 + assert len(balances) == wallet.max_mixdepth + 1 + + +def test_initialize_twice(setup_wallet): + wallet = get_populated_wallet(num=0) + storage = wallet._storage + with pytest.raises(WalletError): + SegwitLegacyWallet.initialize(storage, get_network()) + + +def test_is_known(setup_wallet): + wallet = get_populated_wallet(num=0) + script = wallet.get_new_script(1, True) + addr = wallet.get_new_addr(2, False) + + assert wallet.is_known_script(script) + assert wallet.is_known_addr(addr) + assert wallet.is_known_addr(wallet.script_to_addr(script)) + assert wallet.is_known_script(wallet.addr_to_script(addr)) + + assert not wallet.is_known_script(b'\x12' * len(script)) + assert not wallet.is_known_addr('2MzY5yyonUY7zpHspg7jB7WQs1uJxKafQe4') + + +def test_wallet_save(setup_wallet): + wallet = get_populated_wallet() + + script = wallet.get_external_script(1) + + wallet.save() + storage = wallet._storage + data = storage.file_data + + del wallet + del storage + + storage = VolatileStorage(data=data) + wallet = SegwitLegacyWallet(storage) + + assert wallet.get_next_unused_index(0, True) == 3 + assert wallet.get_next_unused_index(0, False) == 0 + assert wallet.get_next_unused_index(1, True) == 0 + assert wallet.get_next_unused_index(1, False) == 1 + assert wallet.is_known_script(script) + + +def test_set_next_index(setup_wallet): + wallet = get_populated_wallet() + + assert wallet.get_next_unused_index(0, True) == 3 + + with pytest.raises(Exception): + # cannot advance index without force=True + wallet.set_next_index(0, True, 5) + + wallet.set_next_index(0, True, 1) + assert wallet.get_next_unused_index(0, True) == 1 + + wallet.set_next_index(0, True, 20, force=True) + assert wallet.get_next_unused_index(0, True) == 20 + + script = wallet.get_new_script(0, True) + path = wallet.script_to_path(script) + index = wallet.get_details(path)[2] + assert index == 20 + + +def test_path_repr(setup_wallet): + wallet = get_populated_wallet() + path = wallet.get_path(2, False, 0) + path_repr = wallet.get_path_repr(path) + path_new = wallet.path_repr_to_path(path_repr) + + assert path_new == path + + +def test_path_repr_imported(setup_wallet): + wallet = get_populated_wallet(num=0) + path = wallet.import_private_key( + 0, 'cRAGLvPmhpzJNgdMT4W2gVwEW3fusfaDqdQWM2vnWLgXKzCWKtcM', + cryptoengine.TYPE_P2SH_P2WPKH) + path_repr = wallet.get_path_repr(path) + path_new = wallet.path_repr_to_path(path_repr) + + assert path_new == path + + +def test_wrong_wallet_cls(setup_wallet): + storage = VolatileStorage() + SegwitLegacyWallet.initialize(storage, get_network()) + wallet = SegwitLegacyWallet(storage) + + wallet.save() + data = storage.file_data + + del wallet + del storage + + storage = VolatileStorage(data=data) + + with pytest.raises(Exception): + LegacyWallet(storage) + + +def test_wallet_id(setup_wallet): + storage1 = VolatileStorage() + SegwitLegacyWallet.initialize(storage1, get_network()) + wallet1 = SegwitLegacyWallet(storage1) + + storage2 = VolatileStorage() + LegacyWallet.initialize(storage2, get_network(), entropy=wallet1._entropy) + wallet2 = LegacyWallet(storage2) + + assert wallet1.get_wallet_id() != wallet2.get_wallet_id() + + storage2 = VolatileStorage() + SegwitLegacyWallet.initialize(storage2, get_network(), + entropy=wallet1._entropy) + wallet2 = SegwitLegacyWallet(storage2) + + assert wallet1.get_wallet_id() == wallet2.get_wallet_id() + + +def test_addr_script_conversion(setup_wallet): + wallet = get_populated_wallet(num=1) + + path = wallet.get_path(0, True, 0) + script = wallet.get_script_path(path) + addr = wallet.script_to_addr(script) + + assert script == wallet.addr_to_script(addr) + addr_path = wallet.addr_to_path(addr) + assert path == addr_path + + +def test_imported_key_removed(setup_wallet): + wif = 'cRAGLvPmhpzJNgdMT4W2gVwEW3fusfaDqdQWM2vnWLgXKzCWKtcM' + key_type = cryptoengine.TYPE_P2SH_P2WPKH + + storage = VolatileStorage() + SegwitLegacyWallet.initialize(storage, get_network()) + wallet = SegwitLegacyWallet(storage) + + path = wallet.import_private_key(1, wif, key_type) + script = wallet.get_script_path(path) + assert wallet.is_known_script(script) + + wallet.remove_imported_key(path=path) + assert not wallet.is_known_script(script) + + with pytest.raises(WalletError): + wallet.get_script_path(path) + + +@pytest.fixture(scope='module') +def setup_wallet(): + load_program_config() + jm_single().bc_interface.tick_forward_chain_interval = 2 diff --git a/scripts/convert_old_wallet.py b/scripts/convert_old_wallet.py new file mode 100644 index 0000000..390418d --- /dev/null +++ b/scripts/convert_old_wallet.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python2 + +import argparse +import json +import os.path +from hashlib import sha256 +from binascii import hexlify, unhexlify +from collections import defaultdict + +from jmclient import Storage, decryptData, load_program_config +from jmclient.wallet_utils import get_password, get_wallet_cls,\ + cli_get_wallet_passphrase_check, get_wallet_path +from jmbitcoin import wif_compressed_privkey + + +class ConvertException(Exception): + pass + + +def get_max_mixdepth(data): + return max(1, len(data.get('index_cache', [1])) - 1, + *data.get('imported', {}).keys()) + + +def is_encrypted(wallet_data): + return 'encrypted_seed' in wallet_data or 'encrypted_entropy' in wallet_data + + +def double_sha256(plaintext): + return sha256(sha256(plaintext).digest()).digest() + + +def decrypt_entropy_extension(enc_data, key): + data = decryptData(key, unhexlify(enc_data)) + if data[-9] != b'\xff': + raise ConvertException("Wrong password.") + chunks = data.split(b'\xff') + if len(chunks) < 3 or data[-8:] != double_sha256(chunks[1])[:8]: + raise ConvertException("Wrong password.") + return chunks[1] + + +def decrypt_wallet_data(data, password): + key = double_sha256(password.encode('utf-8')) + + enc_entropy = data.get('encrypted_seed') or data.get('encrypted_entropy') + enc_entropy_ext = data.get('encrypted_mnemonic_extension') + enc_imported = data.get('imported_keys') + + entropy = decryptData(key, unhexlify(enc_entropy)) + data['entropy'] = entropy + if enc_entropy_ext: + data['entropy_ext'] = decrypt_entropy_extension(enc_entropy_ext, key) + + if enc_imported: + imported_keys = defaultdict(list) + for e in enc_imported: + md = int(e['mixdepth']) + imported_enc_key = unhexlify(e['encrypted_privkey']) + imported_key = decryptData(key, imported_enc_key) + imported_keys[md].append(imported_key) + data['imported'] = imported_keys + + +def new_wallet_from_data(data, file_name): + print("Creating new wallet file.") + new_pw = cli_get_wallet_passphrase_check() + if new_pw is False: + return False + + storage = Storage(file_name, create=True, password=new_pw) + wallet_cls = get_wallet_cls() + + kwdata = { + 'entropy': data['entropy'], + 'timestamp': data.get('creation_time'), + 'max_mixdepth': get_max_mixdepth(data) + } + + if 'entropy_ext' in data: + kwdata['entropy_extension'] = data['entropy_ext'] + + wallet_cls.initialize(storage, data['network'], **kwdata) + wallet = wallet_cls(storage) + + if 'index_cache' in data: + for md, indices in enumerate(data['index_cache']): + wallet.set_next_index(md, 0, indices[0], force=True) + wallet.set_next_index(md, 1, indices[1], force=True) + + if 'imported' in data: + for md in data['imported']: + for privkey in data['imported'][md]: + privkey += b'\x01' + wif = wif_compressed_privkey(hexlify(privkey)) + wallet.import_private_key(md, wif) + + wallet.save() + wallet.close() + return True + + +def parse_old_wallet(fh): + file_data = json.load(fh) + + if is_encrypted(file_data): + pw = get_password("Enter password for old wallet file: ") + try: + decrypt_wallet_data(file_data, pw) + except ValueError: + print("Failed to open wallet: bad password") + return + except Exception as e: + print("Error: {}".format(e)) + print("Failed to open wallet. Wrong password?") + return + + return file_data + + +def main(): + parser = argparse.ArgumentParser( + description="Convert old joinmarket json wallet format to new jmdat " + "format") + parser.add_argument('old_wallet_file', type=open) + parser.add_argument('--name', '-n', required=False, dest='name', + help="Name of the new wallet file. Default: [old wallet name].jmdat") + + try: + args = parser.parse_args() + except Exception as e: + print("Error: {}".format(e)) + return + + data = parse_old_wallet(args.old_wallet_file) + + if not data: + return + + file_name = args.name or\ + os.path.split(args.old_wallet_file.name)[-1].rsplit('.', 1)[0] + '.jmdat' + wallet_path = get_wallet_path(file_name, None) + if new_wallet_from_data(data, wallet_path): + print("New wallet file created at {}".format(wallet_path)) + else: + print("Failed to convert wallet.") + + +if __name__ == '__main__': + load_program_config() + main()