diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index bdca501..3132a39 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -10,13 +10,14 @@ from btc import * from .support import (calc_cj_fee, choose_sweep_orders, choose_orders, pick_order, cheapest_order_choose, weighted_order_choose, - rand_norm_array, rand_pow_array, rand_exp_array) + rand_norm_array, rand_pow_array, rand_exp_array, select, + select_gradual, select_greedy, select_greediest) from .jsonrpc import JsonRpcError, JsonRpcConnectionError, JsonRpc from .old_mnemonic import mn_decode, mn_encode from .slowaes import decryptData, encryptData from .taker import Taker from .wallet import AbstractWallet, BitcoinCoreInterface, Wallet, \ - BitcoinCoreWallet, estimate_tx_fee, ElectrumWrapWallet + BitcoinCoreWallet, estimate_tx_fee from .configure import load_program_config, jm_single, get_p2pk_vbyte, \ get_network, jm_single, get_network, validate_address, get_irc_mchannels, \ check_utxo_blacklist @@ -33,7 +34,7 @@ from .commitment_utils import get_utxo_info, validate_utxo_data, quit try: from logging import NullHandler -except ImportError: +except ImportError: #pragma: no cover class NullHandler(logging.Handler): def emit(self, record): pass diff --git a/jmclient/jmclient/blockchaininterface.py b/jmclient/jmclient/blockchaininterface.py index 1efc4bb..9a9fb6c 100644 --- a/jmclient/jmclient/blockchaininterface.py +++ b/jmclient/jmclient/blockchaininterface.py @@ -99,7 +99,7 @@ class BlockchainInterface(object): ''' -class ElectrumWalletInterface(BlockchainInterface): +class ElectrumWalletInterface(BlockchainInterface): #pragma: no cover """A pseudo-blockchain interface using the existing Electrum server connection in an Electrum wallet. Usage requires calling set_wallet with a valid Electrum diff --git a/jmclient/jmclient/slowaes.py b/jmclient/jmclient/slowaes.py index b516094..258694f 100644 --- a/jmclient/jmclient/slowaes.py +++ b/jmclient/jmclient/slowaes.py @@ -658,17 +658,3 @@ def decryptData(key, data, mode=AESModeOfOperation.modeOfOperation["CBC"]): decr = strip_PKCS7_padding(decr) return decr -if __name__ == "__main__": - moo = AESModeOfOperation() - cleartext = "This is a test!" - cypherkey = [143, 194, 34, 208, 145, 203, 230, 143, 177, 246, 97, 206, 145, - 92, 255, 84] - iv = [103, 35, 148, 239, 76, 213, 47, 118, 255, 222, 123, 176, 106, 134, 98, - 92] - mode, orig_len, ciph = moo.encrypt(cleartext, moo.modeOfOperation["CBC"], - cypherkey, moo.aes.keySize["SIZE_128"], - iv) - print 'm=%s, ol=%s (%s), ciph=%s' % (mode, orig_len, len(cleartext), ciph) - decr = moo.decrypt(ciph, orig_len, mode, cypherkey, - moo.aes.keySize["SIZE_128"], iv) - print decr diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index bacc7c6..e5aed89 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -109,98 +109,6 @@ class AbstractWallet(object): [addrval['value'] for addrval in utxos.values()]) return mix_balance -class ElectrumWrapWallet(AbstractWallet): - """A thin wrapper class over Electrum's own - wallet for joinmarket compatibility - """ - def __init__(self, ewallet): - self.ewallet = ewallet - #TODO: populate self.unspent with all utxos in Electrum wallet. - - # None is valid for unencrypted electrum wallets; - # calling functions must set the password otherwise - # for private key operations to work - self.password = None - super(ElectrumWrapWallet, self).__init__() - - def get_key_from_addr(self, addr): - if self.ewallet.has_password() and self.password is None: - raise Exception("Cannot extract private key without password") - key = self.ewallet.get_private_key(addr, self.password) - #Convert from wif compressed to hex compressed - #TODO check if compressed - hex_key = btc.from_wif_privkey(key[0], vbyte=get_p2pk_vbyte()) - return hex_key - - def get_external_addr(self, mixdepth): - addr = self.ewallet.get_unused_address() - return addr - - def get_internal_addr(self, mixdepth): - try: - addrs = self.ewallet.get_change_addresses()[ - -self.ewallet.gap_limit_for_change:] - except Exception as e: - log.debug("Failed get change addresses: " + repr(e)) - raise - #filter by unused - try: - change_addrs = [addr for addr in addrs if - self.ewallet.get_num_tx(addr) == 0] - except Exception as e: - log.debug("Failed to filter chadr: " + repr(e)) - raise - #if no unused Electrum re-uses randomly TODO consider - #(of course, all coins in same mixdepth are in principle linkable, - #so I suspect it is better to stick with Electrum's own model, considering - #gap limit issues) - if not change_addrs: - try: - change_addrs = [random.choice(addrs)] - except Exception as e: - log.debug("Failed random: " + repr(e)) - raise - return change_addrs[0] - - def sign_tx(self, tx, addrs): - """tx should be a serialized hex tx. - If self.password is correctly set, - will return the raw transaction with all - inputs from this wallet signed. - """ - if not self.password: - raise Exception("No password, cannot sign") - from electrum.transaction import Transaction - etx = Transaction(tx) - etx.deserialize() - for i in addrs.keys(): - del etx._inputs[i]['scriptSig'] - self.ewallet.add_input_sig_info(etx._inputs[i], addrs[i]) - etx._inputs[i]['address'] = addrs[i] - self.ewallet.sign_transaction(etx, self.password) - return etx.raw - - def sign_message(self, address, message): - #TODO: not currently used, can we use it for auth? - return self.ewallet.sign_message(address, message, self.password) - - def get_utxos_by_mixdepth(self): - """Initial version: all underlying utxos are mixdepth 0. - Format of return is therefore: {0: - {txid:n : {"address": addr, "value": value}, - txid:n: {"address": addr, "value": value},..}} - TODO this should use the account feature in Electrum, - which is exactly that from BIP32, to implement - multiple mixdepths. - """ - ubym = {0:{}} - coins = self.ewallet.get_spendable_coins() - log.debug(pprint.pformat(coins)) - for c in coins: - utxo = c["prevout_hash"] + ":" + str(c["prevout_n"]) - ubym[0][utxo] = {"address": c["address"], "value": c["value"]} - return ubym - class Wallet(AbstractWallet): def __init__(self, seedarg, @@ -218,6 +126,8 @@ class Wallet(AbstractWallet): self.spent_utxos = [] self.imported_privkeys = {} self.seed = self.read_wallet_file_data(seedarg) + if not self.seed: + raise ValueError("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 @@ -245,42 +155,34 @@ class Wallet(AbstractWallet): return 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(): - print ('wallet network(%s) does not match ' + raise ValueError('wallet network(%s) does not match ' 'joinmarket configured network(%s)' % ( walletdata['network'], get_network())) - sys.exit(0) if 'index_cache' in walletdata: self.index_cache = walletdata['index_cache'] - decrypted = False - while not decrypted: - if pwd: - password = pwd - else: - password = getpass('Enter wallet decryption passphrase: ') - password_key = btc.bin_dbl_sha256(password) - encrypted_seed = walletdata['encrypted_seed'] - try: - decrypted_seed = decryptData( - password_key, - encrypted_seed.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_seed) == 32: - decrypted = True - else: - raise ValueError - except ValueError: - print('Incorrect password') - if pwd: - raise - decrypted = False + password_key = btc.bin_dbl_sha256(pwd) + encrypted_seed = walletdata['encrypted_seed'] + try: + decrypted_seed = decryptData( + password_key, + encrypted_seed.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_seed) != 32: + raise ValueError + except ValueError: + log.info('Incorrect password') + return None if self.storepassword: self.password_key = password_key self.walletdata = walletdata @@ -402,7 +304,7 @@ class Wallet(AbstractWallet): return mix_utxo_list -class BitcoinCoreWallet(AbstractWallet): +class BitcoinCoreWallet(AbstractWallet): #pragma: no cover def __init__(self, fromaccount): super(BitcoinCoreWallet, self).__init__() if not isinstance(jm_single().bc_interface, diff --git a/jmclient/test/.coveragerc b/jmclient/test/.coveragerc new file mode 100644 index 0000000..3da71da --- /dev/null +++ b/jmclient/test/.coveragerc @@ -0,0 +1,5 @@ +# .coveragerc to control coverage.py +[run] +omit = + ../jmclient/jsonrpc.py + ../jmclient/btc.py diff --git a/jmclient/test/commontest.py b/jmclient/test/commontest.py new file mode 100644 index 0000000..c825dc6 --- /dev/null +++ b/jmclient/test/commontest.py @@ -0,0 +1,238 @@ +#! /usr/bin/env python +from __future__ import absolute_import +'''Some helper functions for testing''' + +import sys +import os +import time +import binascii +import pexpect +import random +import subprocess +import platform +from decimal import Decimal + +from jmclient import (jm_single, Wallet, get_log, estimate_tx_fee, + BlockchainInterface) +from jmbase.support import chunks +import jmbitcoin as btc + +log = get_log() +'''This code is intended to provide +subprocess startup cross-platform with +some useful options; it could do with +some simplification/improvement.''' +import platform +OS = platform.system() +PINL = '\r\n' if OS == 'Windows' else '\n' + +class DummyBlockchainInterface(BlockchainInterface): + def __init__(self): + self.fake_query_results = None + self.qusfail = False + + def sync_addresses(self, wallet): + pass + def sync_unspent(self, wallet): + pass + def add_tx_notify(self, + txd, + unconfirmfun, + confirmfun, + notifyaddr, + timeoutfun=None): + pass + + def pushtx(self, txhex): + print("pushing: " + str(txhex)) + return True + + def insert_fake_query_results(self, fqr): + self.fake_query_results = fqr + + def setQUSFail(self, state): + self.qusfail = state + + def query_utxo_set(self, txouts,includeconf=False): + if self.qusfail: + #simulate failure to find the utxo + return [None] + if self.fake_query_results: + result = [] + for x in self.fake_query_results: + for y in txouts: + if y == x['utxo']: + result.append(x) + return result + result = [] + #external maker utxos + known_outs = {"03243f4a659e278a1333f8308f6aaf32db4692ee7df0340202750fd6c09150f6:1": "03a2d1cbe977b1feaf8d0d5cc28c686859563d1520b28018be0c2661cf1ebe4857", + "498faa8b22534f3b443c6b0ce202f31e12f21668b4f0c7a005146808f250d4c3:0": "02b4b749d54e96b04066b0803e372a43d6ffa16e75a001ae0ed4b235674ab286be", + "3f3ea820d706e08ad8dc1d2c392c98facb1b067ae4c671043ae9461057bd2a3c:1": "023bcbafb4f68455e0d1d117c178b0e82a84e66414f0987453d78da034b299c3a9"} + #our wallet utxos, faked, for podle tests: utxos are doctored (leading 'f'), + #and the lists are (amt, age) + wallet_outs = {'f34b635ed8891f16c4ec5b8236ae86164783903e8e8bb47fa9ef2ca31f3c2d7a:0': [10000000, 2], + 'f780d6e5e381bff01a3519997bb4fcba002493103a198fde334fd264f9835d75:1': [20000000, 6], + 'fe574db96a4d43a99786b3ea653cda9e4388f377848f489332577e018380cff1:0': [50000000, 3], + 'fd9711a2ef340750db21efb761f5f7d665d94b312332dc354e252c77e9c48349:0': [50000000, 6]} + + if includeconf and set(txouts).issubset(set(wallet_outs)): + #includeconf used as a trigger for a podle check; + #here we simulate a variety of amount/age returns + results = [] + for to in txouts: + results.append({'value': wallet_outs[to][0], + 'confirms': wallet_outs[to][1]}) + return results + if txouts[0] in known_outs: + return [{'value': 200000000, + 'address': btc.pubkey_to_address(known_outs[txouts[0]], magicbyte=0x6f), + 'confirms': 20}] + for t in txouts: + result_dict = {'value': 10000000000, + 'address': "mrcNu71ztWjAQA6ww9kHiW3zBWSQidHXTQ"} + if includeconf: + result_dict['confirms'] = 20 + result.append(result_dict) + return result + + def estimate_fee_per_kb(self, N): + return 30000 + +class TestWallet(Wallet): + """Implementation of wallet + that allows passing in a password + for removal of command line interrupt. + """ + + def __init__(self, + seedarg, + max_mix_depth=2, + gaplimit=6, + extend_mixdepth=False, + storepassword=False, + pwd=None): + self.given_pwd = pwd + super(TestWallet, self).__init__(seedarg, + max_mix_depth, + gaplimit, + extend_mixdepth, + storepassword) + + def read_wallet_file_data(self, filename): + return super(TestWallet, self).read_wallet_file_data( + filename, self.given_pwd) + +def make_sign_and_push(ins_full, + wallet, + amount, + output_addr=None, + change_addr=None, + hashcode=btc.SIGHASH_ALL, + estimate_fee = False): + """Utility function for easily building transactions + from wallets + """ + total = sum(x['value'] for x in ins_full.values()) + ins = ins_full.keys() + #random output address and change addr + output_addr = wallet.get_new_addr(1, 1) if not output_addr else output_addr + change_addr = wallet.get_new_addr(1, 0) if not change_addr else change_addr + fee_est = estimate_tx_fee(len(ins), 2) if estimate_fee else 10000 + outs = [{'value': amount, + 'address': output_addr}, {'value': total - amount - fee_est, + 'address': change_addr}] + + tx = btc.mktx(ins, outs) + de_tx = btc.deserialize(tx) + for index, ins in enumerate(de_tx['ins']): + utxo = ins['outpoint']['hash'] + ':' + str(ins['outpoint']['index']) + addr = ins_full[utxo]['address'] + priv = wallet.get_key_from_addr(addr) + if index % 2: + priv = binascii.unhexlify(priv) + tx = btc.sign(tx, index, priv, hashcode=hashcode) + #pushtx returns False on any error + print btc.deserialize(tx) + push_succeed = jm_single().bc_interface.pushtx(tx) + if push_succeed: + return btc.txhash(tx) + else: + return False + +def local_command(command, bg=False, redirect=''): + if redirect == 'NULL': + if OS == 'Windows': + command.append(' > NUL 2>&1') + elif OS == 'Linux': + command.extend(['>', '/dev/null', '2>&1']) + else: + print "OS not recognised, quitting." + elif redirect: + command.extend(['>', redirect]) + + if bg: + #using subprocess.PIPE seems to cause problems + FNULL = open(os.devnull, 'w') + return subprocess.Popen(command, + stdout=FNULL, + stderr=subprocess.STDOUT, + close_fds=True) + else: + #in case of foreground execution, we can use the output; if not + #it doesn't matter + return subprocess.check_output(command) + + +def make_wallets(n, + wallet_structures=None, + mean_amt=1, + sdev_amt=0, + start_index=0, + fixed_seeds=None, + test_wallet=False, + passwords=None): + '''n: number of wallets to be created + wallet_structure: array of n arrays , each subarray + specifying the number of addresses to be populated with coins + at each depth (for now, this will only populate coins into 'receive' addresses) + mean_amt: the number of coins (in btc units) in each address as above + sdev_amt: if randomness in amouts is desired, specify here. + Returns: a dict of dicts of form {0:{'seed':seed,'wallet':Wallet object},1:..,} + Default Wallet constructor is joinmarket.Wallet, else use TestWallet, + which takes a password parameter as in the list passwords. + ''' + if len(wallet_structures) != n: + raise Exception("Number of wallets doesn't match wallet structures") + if not fixed_seeds: + seeds = chunks(binascii.hexlify(os.urandom(15 * n)), 15 * 2) + else: + seeds = fixed_seeds + wallets = {} + for i in range(n): + if test_wallet: + w = TestWallet(seeds[i], max_mix_depth=5, pwd=passwords[i]) + else: + w = Wallet(seeds[i], max_mix_depth=5) + wallets[i + start_index] = {'seed': seeds[i], + 'wallet': w} + for j in range(5): + for k in range(wallet_structures[i][j]): + deviation = sdev_amt * random.random() + amt = mean_amt - sdev_amt / 2.0 + deviation + if amt < 0: amt = 0.001 + amt = float(Decimal(amt).quantize(Decimal(10)**-8)) + jm_single().bc_interface.grab_coins( + wallets[i + start_index]['wallet'].get_external_addr(j), + amt) + #reset the index so the coins can be seen if running in same script + wallets[i + start_index]['wallet'].index[j][0] -= wallet_structures[i][j] + return wallets + + +def interact(process, inputs, expected): + if len(inputs) != len(expected): + raise Exception("Invalid inputs to interact()") + for i, inp in enumerate(inputs): + process.expect(expected[i]) + process.sendline(inp) diff --git a/jmclient/test/conftest.py b/jmclient/test/conftest.py new file mode 100644 index 0000000..004ba73 --- /dev/null +++ b/jmclient/test/conftest.py @@ -0,0 +1,54 @@ +import pytest +import os +import time +import subprocess +from commontest import local_command +from jmclient import load_program_config + +bitcoin_path = None +bitcoin_conf = None +bitcoin_rpcpassword = None +bitcoin_rpcusername = None + + +def pytest_addoption(parser): + parser.addoption("--btcroot", action="store", default='', + help="the fully qualified path to the directory containing "+\ + "the bitcoin binaries, e.g. /home/user/bitcoin/bin/") + parser.addoption("--btcconf", action="store", + help="the fully qualified path to the location of the "+\ + "bitcoin configuration file you use for testing, e.g. "+\ + "/home/user/.bitcoin/bitcoin.conf") + parser.addoption("--btcpwd", + action="store", + help="the RPC password for your test bitcoin instance") + parser.addoption("--btcuser", + action="store", + default='bitcoinrpc', + help="the RPC username for your test bitcoin instance (default=bitcoinrpc)") + +def teardown(): + #shut down bitcoin and remove the regtest dir + local_command([bitcoin_path + "bitcoin-cli", "-regtest", "-rpcuser=" + bitcoin_rpcusername, + "-rpcpassword=" + bitcoin_rpcpassword, "stop"]) + #note, it is better to clean out ~/.bitcoin/regtest but too + #dangerous to automate it here perhaps + + +@pytest.fixture(scope="session", autouse=True) +def setup(request): + request.addfinalizer(teardown) + + global bitcoin_conf, bitcoin_path, bitcoin_rpcpassword, bitcoin_rpcusername + bitcoin_path = request.config.getoption("--btcroot") + bitcoin_conf = request.config.getoption("--btcconf") + bitcoin_rpcpassword = request.config.getoption("--btcpwd") + bitcoin_rpcusername = request.config.getoption("--btcuser") + + #start up regtest blockchain + btc_proc = subprocess.call([bitcoin_path + "bitcoind", "-regtest", + "-daemon", "-conf=" + bitcoin_conf]) + time.sleep(3) + #generate blocks + local_command([bitcoin_path + "bitcoin-cli", "-regtest", "-rpcuser=" + bitcoin_rpcusername, + "-rpcpassword=" + bitcoin_rpcpassword, "generate", "101"]) diff --git a/jmclient/test/test_aes.py b/jmclient/test/test_aes.py new file mode 100644 index 0000000..e093b9e --- /dev/null +++ b/jmclient/test/test_aes.py @@ -0,0 +1,29 @@ +import jmclient.slowaes as sa +"""test general AES operation; probably not needed. + Not included in coverage, but should be included in suite.""" +import os +import pytest + +def test_pkcs7_bad_padding(): + #used in seed decryption; check that it throws + #if wrongly padded (this caused a REAL bug before!) + bad_padded = ['\x07'*14, '\x07'*31, '\x07'*31+'\x11', '\x07'*31+'\x00', + '\x07'*14+'\x01\x02'] + for b in bad_padded: + with pytest.raises(Exception) as e_info: + fake_unpadded = sa.strip_PKCS7_padding(b) + +def test_aes(): + cleartext = "This is a test!" + iv = [103, 35, 148, 239, 76, 213, 47, 118, 255, 222, 123, 176, 106, 134, 98, + 92] + for ks in [16,24,32]: + for mode in ["CFB", "CBC", "OFB"]: + cypherkey = map(ord, os.urandom(ks)) + moo = sa.AESModeOfOperation() + mode, orig_len, ciph = moo.encrypt(cleartext, moo.modeOfOperation[mode], + cypherkey, ks, + iv) + decr = moo.decrypt(ciph, orig_len, mode, cypherkey, + ks, iv) + assert decr==cleartext \ No newline at end of file diff --git a/jmclient/test/test_mnemonic.py b/jmclient/test/test_mnemonic.py new file mode 100644 index 0000000..2c703f5 --- /dev/null +++ b/jmclient/test/test_mnemonic.py @@ -0,0 +1,55 @@ +from jmclient import old_mnemonic + +import pytest + +@pytest.mark.parametrize( + "seedphrase, key, valid", + [ + (["spiral", "squeeze", "strain", "sunset", "suspend", "sympathy", + "thigh", "throne", "total", "unseen", "weapon", "weary"], + '0028644c0028644f0028645200286455', + True), + (["pair", "bury", "lung", "swim", "orange", "doctor", "numb", "interest", + "shock", "bloom", "fragile", "screen"], + 'fa92999d01431f961a26c876f55d3f6c', + True), + (["check", "squeeze", "strain", "sunset", "suspend", "sympathy", + "thigh", "throne", "total", "unseen", "weapon", "weary"], + '0028644c0028644f0028645200286455', + False), + (["qwerty", "check", "strain", "sunset", "suspend", "sympathy", + "thigh", "throne", "total", "unseen", "weapon", "weary"], + '', + False), + (["", "check", "strain", "sunset", "suspend", "sympathy", + "thigh", "throne", "total", "unseen", "weapon", "weary"], + '', + False), + (["strain", "sunset"], + '', + False), + ]) +def test_old_mnemonic(seedphrase, key, valid): + if valid: + assert old_mnemonic.mn_decode(seedphrase) == key + assert old_mnemonic.mn_encode(key) == seedphrase + else: + if len(key) > 0: + #test cases where the seedphrase is valid + #but must not match the provided master private key + assert old_mnemonic.mn_decode(seedphrase) != key + else: + #test cases where the seedphrase is intrinsically invalid + #Already known error condition: an incorrectly short + #word list will NOT throw an error; this is handled by calling code + if len(seedphrase) < 12: + print "For known failure case of seedphrase less than 12: " + print old_mnemonic.mn_decode(seedphrase) + else: + with pytest.raises(Exception) as e_info: + dummy = old_mnemonic.mn_decode(seedphrase) + print "Got this return value from mn_decode: " + str(dummy) + + + + diff --git a/jmclient/test/test_taker.py b/jmclient/test/test_taker.py index af51a13..5c52893 100644 --- a/jmclient/test/test_taker.py +++ b/jmclient/test/test_taker.py @@ -1,8 +1,6 @@ #!/usr/bin/env python from __future__ import print_function -from jmclient import AbstractWallet -from jmclient import BlockchainInterface -from jmclient import Taker +from commontest import DummyBlockchainInterface import jmbitcoin as bitcoin import binascii import os @@ -12,7 +10,7 @@ import pytest import json from base64 import b64encode from jmclient import (load_program_config, jm_single, set_commitment_file, - get_commitment_file) + get_commitment_file, AbstractWallet, Taker) from taker_test_data import (t_utxos_by_mixdepth, t_selected_utxos, t_orderbook, t_maker_response, t_chosen_orders, t_dummy_ext) @@ -58,79 +56,6 @@ class DummyWallet(AbstractWallet): return binascii.hexlify(p) raise ValueError("No such keypair") -class DummyBlockchainInterface(BlockchainInterface): - def __init__(self): - self.fake_query_results = None - self.qusfail = False - - def sync_addresses(self, wallet): - pass - def sync_unspent(self, wallet): - pass - def add_tx_notify(self, - txd, - unconfirmfun, - confirmfun, - notifyaddr, - timeoutfun=None): - pass - - def pushtx(self, txhex): - print("pushing: " + str(txhex)) - return True - - def insert_fake_query_results(self, fqr): - self.fake_query_results = fqr - - def setQUSFail(self, state): - self.qusfail = state - - def query_utxo_set(self, txouts,includeconf=False): - if self.qusfail: - #simulate failure to find the utxo - return [None] - if self.fake_query_results: - result = [] - for x in self.fake_query_results: - for y in txouts: - if y == x['utxo']: - result.append(x) - return result - result = [] - #external maker utxos - known_outs = {"03243f4a659e278a1333f8308f6aaf32db4692ee7df0340202750fd6c09150f6:1": "03a2d1cbe977b1feaf8d0d5cc28c686859563d1520b28018be0c2661cf1ebe4857", - "498faa8b22534f3b443c6b0ce202f31e12f21668b4f0c7a005146808f250d4c3:0": "02b4b749d54e96b04066b0803e372a43d6ffa16e75a001ae0ed4b235674ab286be", - "3f3ea820d706e08ad8dc1d2c392c98facb1b067ae4c671043ae9461057bd2a3c:1": "023bcbafb4f68455e0d1d117c178b0e82a84e66414f0987453d78da034b299c3a9"} - #our wallet utxos, faked, for podle tests: utxos are doctored (leading 'f'), - #and the lists are (amt, age) - wallet_outs = {'f34b635ed8891f16c4ec5b8236ae86164783903e8e8bb47fa9ef2ca31f3c2d7a:0': [10000000, 2], - 'f780d6e5e381bff01a3519997bb4fcba002493103a198fde334fd264f9835d75:1': [20000000, 6], - 'fe574db96a4d43a99786b3ea653cda9e4388f377848f489332577e018380cff1:0': [50000000, 3], - 'fd9711a2ef340750db21efb761f5f7d665d94b312332dc354e252c77e9c48349:0': [50000000, 6]} - - if includeconf and set(txouts).issubset(set(wallet_outs)): - #includeconf used as a trigger for a podle check; - #here we simulate a variety of amount/age returns - results = [] - for to in txouts: - results.append({'value': wallet_outs[to][0], - 'confirms': wallet_outs[to][1]}) - return results - if txouts[0] in known_outs: - return [{'value': 200000000, - 'address': bitcoin.pubkey_to_address(known_outs[txouts[0]], magicbyte=0x6f), - 'confirms': 20}] - for t in txouts: - result_dict = {'value': 10000000000, - 'address': "mrcNu71ztWjAQA6ww9kHiW3zBWSQidHXTQ"} - if includeconf: - result_dict['confirms'] = 20 - result.append(result_dict) - return result - - def estimate_fee_per_kb(self, N): - return 30000 - def dummy_order_chooser(): return t_chosen_orders diff --git a/jmclient/test/test_wallets.py b/jmclient/test/test_wallets.py new file mode 100644 index 0000000..f0307a7 --- /dev/null +++ b/jmclient/test/test_wallets.py @@ -0,0 +1,251 @@ +#! /usr/bin/env python +from __future__ import absolute_import +'''Wallet functionality tests.''' + +import sys +import os +import time +import binascii +import pexpect +import random +import subprocess +import datetime +import unittest +from ConfigParser import SafeConfigParser, NoSectionError +from decimal import Decimal +from commontest import (local_command, interact, make_wallets, make_sign_and_push, + DummyBlockchainInterface, TestWallet) +import json + +import jmbitcoin as bitcoin +import pytest +from jmclient import (load_program_config, jm_single, sync_wallet, AbstractWallet, + get_p2pk_vbyte, get_log, Wallet, select, select_gradual, + select_greedy, select_greediest, estimate_tx_fee, encryptData, + get_network) +from jmbase.support import chunks +from taker_test_data import t_obtained_tx + +log = get_log() + +def do_tx(wallet, amount): + ins_full = wallet.select_utxos(0, amount) + cj_addr = wallet.get_internal_addr(1) + change_addr = wallet.get_internal_addr(0) + wallet.update_cache_index() + txid = make_sign_and_push(ins_full, wallet, amount, + output_addr=cj_addr, + change_addr=change_addr, + estimate_fee=True) + assert txid + time.sleep(2) #blocks + jm_single().bc_interface.sync_unspent(wallet) + +def test_absurd_fee(setup_wallets): + jm_single().config.set("POLICY", "absurd_fee_per_kb", "1000") + with pytest.raises(ValueError) as e_info: + estimate_tx_fee(10,2) + load_program_config() + +def test_abstract_wallet(setup_wallets): + class DoNothingWallet(AbstractWallet): + pass + for algo in ["default", "gradual", "greedy", "greediest", "none"]: + jm_single().config.set("POLICY", "merge_algorithm", algo) + if algo == "none": + with pytest.raises(Exception) as e_info: + dnw = DoNothingWallet() + #also test if the config is blank + jm_single().config = SafeConfigParser() + dnw = DoNothingWallet() + assert dnw.utxo_selector == select + else: + dnw = DoNothingWallet() + assert not dnw.get_key_from_addr("a") + assert not dnw.get_utxos_by_mixdepth() + assert not dnw.get_external_addr(1) + assert not dnw.get_internal_addr(0) + dnw.update_cache_index() + dnw.remove_old_utxos("a") + dnw.add_new_utxos("b", "c") + load_program_config() + +def create_default_testnet_wallet(): + walletdir = "wallets" + testwalletname = "testwallet.json" + pathtowallet = os.path.join(walletdir, testwalletname) + if os.path.exists(pathtowallet): + os.remove(pathtowallet) + seed = "hello" + return (walletdir, pathtowallet, testwalletname, Wallet(seed, + 5, + 6, + extend_mixdepth=False, + storepassword=False)) + +@pytest.mark.parametrize( + "includecache, wrongnet, storepwd, extendmd, pwdnumtries", + [ + (False, False, False, False, 1000), + (True, False, False, True, 1), + (False, True, False, False, 1), + (False, False, True, False, 1) + ]) +def test_wallet_create(setup_wallets, includecache, wrongnet, storepwd, extendmd, + pwdnumtries): + walletdir, pathtowallet, testwalletname, wallet = create_default_testnet_wallet() + assert wallet.get_key(4,1,17) == "1289ca322f96673acef83f396a9735840e3ab69f0459cf9bfa8d9985a876534401" + assert wallet.get_addr(2,0,5) == "myWPu9QJWHGE79XAmuKkwKgNk8vsr5evpk" + jm_single().bc_interface.wallet_synced = True + assert wallet.get_new_addr(1, 0) == "mi88ZgDGPmarzcsU6S437h9CY9BLmgH5M6" + assert wallet.get_external_addr(3) == "mvChQuChnXVhqvH67wfMxrodPQ7xccdVJU" + addr3internal = wallet.get_internal_addr(3) + assert addr3internal == "mv26o79Bauf2miJMoxoSu1vXmfXnk85YPQ" + assert wallet.get_key_from_addr(addr3internal) == "2a283c9a2168a25509e2fb944939637228c50c8b4fecd9024650316c4584246501" + dummyaddr = "mvw1NazKDRbeNufFANqpYNAANafsMC2zVU" + assert not wallet.get_key_from_addr(dummyaddr) + #Make a new Wallet(), and prepare a testnet wallet file for this wallet + + password = "dummypassword" + password_key = bitcoin.bin_dbl_sha256(password) + seed = bitcoin.sha256("\xaa"*64)[:32] + encrypted_seed = encryptData(password_key, seed.decode('hex')) + timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") + net = get_network() if not wrongnet else 'mainnnet' + walletfilejson = {'creator': 'joinmarket project', + 'creation_time': timestamp, + 'encrypted_seed': encrypted_seed.encode('hex'), + 'network': net} + if includecache: + mmd = wallet.max_mix_depth if not extendmd else wallet.max_mix_depth + 5 + print("using mmd: " + str(mmd)) + walletfilejson.update({'index_cache': [[0,0]]*mmd}) + walletfile = json.dumps(walletfilejson) + if not os.path.exists(walletdir): + os.makedirs(walletdir) + with open(pathtowallet, "wb") as f: + f.write(walletfile) + if wrongnet: + with pytest.raises(ValueError) as e_info: + TestWallet(testwalletname, 5, 6, extend_mixdepth=extendmd, + storepassword=storepwd, pwd=password) + return + from string import ascii_letters + for i in range(pwdnumtries): #multiple tries to ensure pkcs7 error is triggered + with pytest.raises(ValueError) as e_info: + wrongpwd = "".join([random.choice(ascii_letters) for _ in range(20)]) + TestWallet(testwalletname, 5, 6, extend_mixdepth=extendmd, + storepassword=storepwd, pwd=wrongpwd) + + with pytest.raises(ValueError) as e_info: + TestWallet(testwalletname, 5, 6, extend_mixdepth=extendmd, + storepassword=storepwd, pwd=None) + newwallet = TestWallet(testwalletname, 5, 6, extend_mixdepth=extendmd, + storepassword=storepwd, pwd=password) + assert newwallet.seed == seed + #now we have a functional wallet + file, update the cache; first try + #with failed paths + oldpath = newwallet.path + newwallet.path = None + newwallet.update_cache_index() + newwallet.path = "fake-path-definitely-doesnt-exist" + newwallet.update_cache_index() + #with real path + newwallet.path = oldpath + newwallet.index = [[1,1]]*5 + newwallet.update_cache_index() + + #ensure we cannot find a mainnet wallet from seed + seed = "goodbye" + jm_single().config.set("BLOCKCHAIN", "network", "mainnet") + with pytest.raises(IOError) as e_info: + Wallet(seed, 5, 6, False, False) + load_program_config() + +def test_imported_privkey(setup_wallets): + jm_single().config.set("BLOCKCHAIN", "network", "mainnet") + password = "dummypassword" + password_key = bitcoin.bin_dbl_sha256(password) + privkey = "L1RrrnXkcKut5DEMwtDthjwRcTTwED36thyL1DebVrKuwvohjMNi" + #to verify use from_wif_privkey and privkey_to_address + iaddr = "1LDsjB43N2NAQ1Vbc2xyHca4iBBciN8iwC" + privkey_bin = bitcoin.from_wif_privkey(privkey, + vbyte=get_p2pk_vbyte()).decode('hex')[:-1] + encrypted_privkey = encryptData(password_key, privkey_bin) + encrypted_privkey_bad = encryptData(password_key, privkey_bin[:6]) + walletdir = "wallets" + testwalletname = "testreal" + pathtowallet = os.path.join(walletdir, testwalletname) + seed = bitcoin.sha256("\xaa"*64)[:32] + encrypted_seed = encryptData(password_key, seed.decode('hex')) + timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S") + for ep in [encrypted_privkey, encrypted_privkey_bad]: + walletfilejson = {'creator': 'joinmarket project', + 'creation_time': timestamp, + 'encrypted_seed': encrypted_seed.encode('hex'), + 'network': get_network(), + 'index_cache': [[0,0]]*5, + 'imported_keys': [ + {'encrypted_privkey': ep.encode('hex'), + 'mixdepth': 0}]} + walletfile = json.dumps(walletfilejson) + if not os.path.exists(walletdir): + os.makedirs(walletdir) + with open(pathtowallet, "wb") as f: + f.write(walletfile) + if ep == encrypted_privkey_bad: + with pytest.raises(Exception) as e_info: + TestWallet(testwalletname, 5, 6, False, False, pwd=password) + continue + newwallet = TestWallet(testwalletname, 5, 6, False, False, pwd=password) + assert newwallet.seed == seed + #test accessing the key from the addr + assert newwallet.get_key_from_addr(iaddr) == bitcoin.from_wif_privkey(privkey) + load_program_config() + +def test_add_remove_utxos(setup_wallets): + #Make a fake wallet and inject and then remove fake utxos + walletdir, pathtowallet, testwalletname, wallet = create_default_testnet_wallet() + assert wallet.get_addr(2,0,5) == "myWPu9QJWHGE79XAmuKkwKgNk8vsr5evpk" + wallet.addr_cache["myWPu9QJWHGE79XAmuKkwKgNk8vsr5evpk"] = (2, 0, 5) + #'76a914c55738deaa9861b6022e53a129968cbf354898b488ac' + #these calls automatically update the addr_cache: + assert wallet.get_new_addr(1, 0) == "mi88ZgDGPmarzcsU6S437h9CY9BLmgH5M6" + #76a9141c9761f5fef73bef6aca378c930c59e7e795088488ac + assert wallet.get_external_addr(3) == "mvChQuChnXVhqvH67wfMxrodPQ7xccdVJU" + #76a914a115fa0394ce881437a96d443e236b39e07db1f988ac + #using the above pubkey scripts: + faketxforwallet = {'outs': + [{'script': '76a914c55738deaa9861b6022e53a129968cbf354898b488ac', + 'value': 110000000}, + {'script': '76a9141c9761f5fef73bef6aca378c930c59e7e795088488ac', + 'value': 89910900}, + {'script': '76a914a115fa0394ce881437a96d443e236b39e07db1f988ac', + 'value': 90021000}, + {'script': '76a9145ece2dac945c8ff5b2b6635360ca0478ade305d488ac', #not ours + 'value': 110000000}], + 'version': 1} + wallet.add_new_utxos(faketxforwallet, "aa"*32) + faketxforspending = {'ins': + [{'outpoint': {'hash': 'aa'*32, + 'index': 0}}, + {'outpoint': {'hash': 'aa'*32, + 'index': 1}}, + {'outpoint': {'hash': 'aa'*32, + 'index': 2}}, + {'outpoint': {'hash': '3f3ea820d706e08ad8dc1d2c392c98facb1b067ae4c671043ae9461057bd2a3c', + 'index': 1}, + 'script': '', + 'sequence': 4294967295}]} + wallet.select_utxos(1, 100000) + with pytest.raises(Exception) as e_info: + wallet.select_utxos(0, 100000) + #ensure get_utxos_by_mixdepth can handle utxos outside of maxmixdepth + wallet.max_mix_depth = 2 + mul = wallet.get_utxos_by_mixdepth() + assert mul[3] != {} + wallet.remove_old_utxos(faketxforspending) + +@pytest.fixture(scope="module") +def setup_wallets(): + load_program_config()