You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
464 lines
18 KiB
464 lines
18 KiB
from __future__ import print_function |
|
import json |
|
import os |
|
import pprint |
|
import sys |
|
from decimal import Decimal |
|
|
|
from ConfigParser import NoSectionError |
|
from getpass import getpass |
|
|
|
import btc |
|
from client.slowaes import decryptData |
|
from client.blockchaininterface import BitcoinCoreInterface, RegtestBitcoinCoreInterface |
|
from client.configure import jm_single, get_network, get_p2pk_vbyte |
|
from base.support import get_log |
|
from client.support import select_gradual, select_greedy,select_greediest, select |
|
|
|
log = get_log() |
|
|
|
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, |
|
based on information from the blockchain interface. |
|
''' |
|
tx_estimated_bytes = btc.estimate_tx_size(ins, outs, txtype) |
|
log.debug("Estimated transaction size: "+str(tx_estimated_bytes)) |
|
fee_per_kb = jm_single().bc_interface.estimate_fee_per_kb( |
|
jm_single().config.getint("POLICY", "tx_fees")) |
|
absurd_fee = jm_single().config.getint("POLICY", "absurd_fee_per_kb") |
|
if fee_per_kb > absurd_fee: |
|
#This error is considered critical; for safety reasons, shut down. |
|
raise ValueError("Estimated fee per kB greater than absurd value: " + \ |
|
str(absurd_fee) + ", quitting.") |
|
log.debug("got estimated tx bytes: "+str(tx_estimated_bytes)) |
|
return int((tx_estimated_bytes * fee_per_kb)/Decimal(1000.0)) |
|
|
|
class AbstractWallet(object): |
|
""" |
|
Abstract wallet for use with JoinMarket |
|
Mostly written with Wallet in mind, the default JoinMarket HD wallet |
|
""" |
|
|
|
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 |
|
|
|
def get_key_from_addr(self, addr): |
|
return None |
|
|
|
def get_utxos_by_mixdepth(self): |
|
return None |
|
|
|
def get_external_addr(self, mixing_depth): |
|
""" |
|
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 |
|
|
|
def get_internal_addr(self, mixing_depth): |
|
""" |
|
Return an address for internal usage, as change addresses and when |
|
participating in transactions initiated by other parties. |
|
""" |
|
return None |
|
|
|
def update_cache_index(self): |
|
pass |
|
|
|
def remove_old_utxos(self, tx): |
|
pass |
|
|
|
def add_new_utxos(self, tx, txid): |
|
pass |
|
|
|
def select_utxos(self, mixdepth, amount): |
|
utxo_list = self.get_utxos_by_mixdepth()[mixdepth] |
|
unspent = [{'utxo': utxo, |
|
'value': addrval['value']} |
|
for utxo, addrval in utxo_list.iteritems()] |
|
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_balance_by_mixdepth(self): |
|
mix_balance = {} |
|
for m in range(self.max_mix_depth): |
|
mix_balance[m] = 0 |
|
for mixdepth, utxos in self.get_utxos_by_mixdepth().iteritems(): |
|
mix_balance[mixdepth] = sum( |
|
[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") |
|
log.debug("in get key from addr") |
|
log.debug("password is: " + str(self.password)) |
|
log.debug("address is: " + str(addr)) |
|
key = self.ewallet.get_private_key(addr, self.password) |
|
#TODO remove after testing! |
|
log.debug("Got WIF key: " + str(key)) |
|
#Convert from wif compressed to hex compressed |
|
#TODO check if compressed |
|
hex_key = btc.from_wif_privkey(key[0], vbyte=get_p2pk_vbyte()) |
|
log.debug("Got hex key: " + str(hex_key)) |
|
return hex_key |
|
|
|
def get_external_addr(self, mixdepth): |
|
addr = self.ewallet.get_unused_address() |
|
log.debug("Retrieved unused: " + addr) |
|
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] |
|
log.debug("Input is now: " + str(etx._inputs[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, |
|
max_mix_depth=2, |
|
gaplimit=6, |
|
extend_mixdepth=False, |
|
storepassword=False): |
|
super(Wallet, self).__init__() |
|
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.read_wallet_file_data(seedarg) |
|
if extend_mixdepth and len(self.index_cache) > max_mix_depth: |
|
self.max_mix_depth = len(self.index_cache) |
|
self.gaplimit = gaplimit |
|
master = btc.bip32_master_key(self.seed, (btc.MAINNET_PRIVATE if |
|
get_network() == 'mainnet' else btc.TESTNET_PRIVATE)) |
|
m_0 = btc.bip32_ckd(master, 0) |
|
mixing_depth_keys = [btc.bip32_ckd(m_0, c) |
|
for c in range(self.max_mix_depth)] |
|
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 read_wallet_file_data(self, filename, pwd=None): |
|
self.path = None |
|
self.index_cache = [[0, 0]] * self.max_mix_depth |
|
path = os.path.join('wallets', 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 filename |
|
else: |
|
raise IOError('wallet file not found') |
|
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 ' |
|
'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 |
|
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) |
|
return decrypted_seed |
|
|
|
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): |
|
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): |
|
# 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) |
|
|
|
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]] |
|
|
|
def remove_old_utxos(self, tx): |
|
removed_utxos = {} |
|
for ins in tx['ins']: |
|
utxo = ins['outpoint']['hash'] + ':' + str(ins['outpoint']['index']) |
|
if utxo not in self.unspent: |
|
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())) |
|
self.spent_utxos += removed_utxos.keys() |
|
return removed_utxos |
|
|
|
def add_new_utxos(self, tx, txid): |
|
added_utxos = {} |
|
for index, outs in enumerate(tx['outs']): |
|
addr = btc.script_to_address(outs['script'], get_p2pk_vbyte()) |
|
if addr not in self.addr_cache: |
|
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())) |
|
return added_utxos |
|
|
|
def get_utxos_by_mixdepth(self): |
|
""" |
|
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 |
|
log.debug('get_utxos_by_mixdepth = \n' + pprint.pformat(mix_utxo_list)) |
|
return mix_utxo_list |
|
|
|
|
|
class BitcoinCoreWallet(AbstractWallet): |
|
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 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 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): |
|
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.
|
|
|