From dc715c9481e6a5299a552893e6597c5c34643c59 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Thu, 23 Jan 2020 21:16:35 +0000 Subject: [PATCH] Add timelock fidelity bond wallet sync and display Fidelity bond wallets are intended to be used when at a later date using fidelity bonds to greatly increase joinmarket's resistance to sybil attacks. This commit adds support for timelocked addresses. It allows users to optionally create wallet which support such addresses. The synchronization code is modified to also scan for timelocked addresses. The keypairs of the timelocked addresses go in the newly created 2nd address type, where before the zeroth index were receive addresses and first index was change. The locktime dates are fixed at the first of each month for the next 30 years. This means users dont need to remember any dates, and so just their seed phrase and wallet type will still be enough to recover all funds. Each keypair used for timelocking requires an additional 360 addresses to be scanned for, which isn't a problem for Bitcoin Core. Fidelity bonds are only stored in the zeroth mixdepth, as they are not used in repeated coinjoins so theres no point having them in multiple mixdepths. Timelocked addresses don't use the get_new_script() family of functions because they all assume that one index equals one address, and that therefore it's possible to ask for a "next" address. For timelocked addresses knowing the index is not enough to know the address, the timestamp must be known too. Also once one address made of (index, timestamp) is used you mustn't use that index and pubkey again, even though all the other timelocks for that index/pubkey are unused. This is for privacy reasons, as its equivalent to address reuse. --- jmbitcoin/jmbitcoin/secp256k1_main.py | 2 +- jmclient/jmclient/__init__.py | 2 +- jmclient/jmclient/cryptoengine.py | 60 ++++++- jmclient/jmclient/wallet.py | 215 ++++++++++++++++++++++++-- jmclient/jmclient/wallet_service.py | 28 ++++ jmclient/jmclient/wallet_utils.py | 131 +++++++++++++--- scripts/joinmarket-qt.py | 3 +- 7 files changed, 397 insertions(+), 44 deletions(-) diff --git a/jmbitcoin/jmbitcoin/secp256k1_main.py b/jmbitcoin/jmbitcoin/secp256k1_main.py index 931b424..e91fc59 100644 --- a/jmbitcoin/jmbitcoin/secp256k1_main.py +++ b/jmbitcoin/jmbitcoin/secp256k1_main.py @@ -10,7 +10,7 @@ import coincurve as secp256k1 #Required only for PoDLE calculation: N = 115792089237316195423570985008687907852837564279074904382605163141518161494337 -BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f'} +BTC_P2PK_VBYTE = {"mainnet": b'\x00', "testnet": b'\x6f', "regtest": 100} BTC_P2SH_VBYTE = {"mainnet": b'\x05', "testnet": b'\xc4'} #Standard prefix for Bitcoin message signing. diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index 04d5aa4..c00fe7e 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -13,7 +13,7 @@ from .old_mnemonic import mn_decode, mn_encode from .taker import Taker, P2EPTaker from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportWalletMixin, BIP39WalletMixin, BIP32Wallet, BIP49Wallet, LegacyWallet, - SegwitWallet, SegwitLegacyWallet, UTXOManager, + SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) diff --git a/jmclient/jmclient/cryptoengine.py b/jmclient/jmclient/cryptoengine.py index e537b8f..24ccfe9 100644 --- a/jmclient/jmclient/cryptoengine.py +++ b/jmclient/jmclient/cryptoengine.py @@ -5,10 +5,11 @@ from collections import OrderedDict import struct import jmbitcoin as btc -from .configure import get_network +from .configure import get_network, jm_single -TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N = range(4) +TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N, TYPE_TIMELOCK_P2WSH, \ + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS = range(6) NET_MAINNET, NET_TESTNET = range(2) NET_MAP = {'mainnet': NET_MAINNET, 'testnet': NET_TESTNET} WIF_PREFIX_MAP = {'mainnet': b'\x80', 'testnet': b'\xef'} @@ -283,8 +284,61 @@ class BTC_P2WPKH(BTCEngine): return btc.sign(btc.serialize(tx), index, privkey, hashcode=hashcode, amount=amount, native=True) +class BTC_Timelocked_P2WSH(BTCEngine): + + """ + In this class many instances of "privkey" or "pubkey" are actually tuples + of (privkey, timelock) or (pubkey, timelock) + """ + + @classproperty + def VBYTE(cls): + #slight hack here, network can be either "mainnet" or "testnet" + #but we need to distinguish between actual testnet and regtest + if get_network() == "mainnet": + return btc.BTC_P2PK_VBYTE["mainnet"] + else: + if jm_single().config.get("BLOCKCHAIN", "blockchain_source")\ + == "regtest": + return btc.BTC_P2PK_VBYTE["regtest"] + else: + assert get_network() == "testnet" + return btc.BTC_P2PK_VBYTE["testnet"] + + @classmethod + def privkey_to_script(cls, privkey_locktime): + privkey, locktime = privkey_locktime + pub = cls.privkey_to_pubkey(privkey) + return cls.pubkey_to_script((pub, locktime)) + + @classmethod + def pubkey_to_script(cls, pubkey_locktime): + redeem_script = cls.pubkey_to_script_code(pubkey_locktime) + return btc.redeem_script_to_p2wsh_script(redeem_script) + + @classmethod + def pubkey_to_script_code(cls, pubkey_locktime): + pubkey, locktime = pubkey_locktime + return btc.mk_freeze_script(pubkey, locktime) + + @classmethod + def privkey_to_wif(cls, privkey_locktime): + priv, locktime = privkey_locktime + return btc.bin_to_b58check(priv, cls.WIF_PREFIX) + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise Exception("not implemented yet") + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + raise RuntimeError("Cannot spend from watch-only wallets") + ENGINES = { TYPE_P2PKH: BTC_P2PKH, TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH, - TYPE_P2WPKH: BTC_P2WPKH + TYPE_P2WPKH: BTC_P2WPKH, + TYPE_TIMELOCK_P2WSH: BTC_Timelocked_P2WSH } diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 3778c4a..3f1dae5 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -7,6 +7,7 @@ import numbers import random from binascii import hexlify, unhexlify from datetime import datetime +from calendar import timegm from copy import deepcopy from mnemonic import Mnemonic as MnemonicParent from hashlib import sha256 @@ -20,7 +21,8 @@ from .blockchaininterface import INF_HEIGHT from .support import select_gradual, select_greedy, select_greediest, \ select from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH,\ - TYPE_P2WPKH, ENGINES + TYPE_P2WPKH, TYPE_TIMELOCK_P2WSH, TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS,\ + ENGINES from .support import get_random_bytes from . import mn_encode, mn_decode import jmbitcoin as btc @@ -559,7 +561,7 @@ class BaseWallet(object): returns: tuple (mixdepth, type, index) - type is one of 0, 1, 'imported' + type is one of 0, 1, 'imported', 2, 3 """ raise NotImplementedError() @@ -976,27 +978,28 @@ class BaseWallet(object): def rewind_wallet_indices(self, used_indices, saved_indices): for md in used_indices: - for address_type in (self.ADDRESS_TYPE_EXTERNAL, - self.ADDRESS_TYPE_INTERNAL): + for address_type in range(min(len(used_indices[md]), len(saved_indices[md]))): index = max(used_indices[md][address_type], saved_indices[md][address_type]) self.set_next_index(md, address_type, index, force=True) + def _get_default_used_indices(self): + return {x: [0, 0] for x in range(self.max_mixdepth + 1)} + def get_used_indices(self, addr_gen): """ Returns a dict of max used indices for each branch in the wallet, from the given addresses addr_gen, assuming that they are known to the wallet. """ - - indices = {x: [0, 0] for x in range(self.max_mixdepth + 1)} + indices = self._get_default_used_indices() for addr in addr_gen: if not self.is_known_addr(addr): continue md, address_type, index = self.get_details( self.addr_to_path(addr)) - if address_type not in (self.ADDRESS_TYPE_EXTERNAL, - self.ADDRESS_TYPE_INTERNAL): + if address_type not in (self.BIP32_EXT_ID, self.BIP32_INT_ID, + FidelityBondMixin.BIP32_TIMELOCK_ID, FidelityBondMixin.BIP32_BURN_ID): assert address_type == 'imported' continue indices[md][address_type] = max(indices[md][address_type], index + 1) @@ -1379,6 +1382,10 @@ class BIP32Wallet(BaseWallet): def _derive_bip32_master_key(cls, seed): return cls._ENGINE.derive_bip32_master_key(seed) + @classmethod + def _get_supported_address_types(cls): + return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID) + def get_script_from_path(self, path): if not self._is_my_bip32_path(path): raise WalletError("unable to get script for unknown key path") @@ -1387,11 +1394,14 @@ class BIP32Wallet(BaseWallet): if not 0 <= md <= self.max_mixdepth: raise WalletError("Mixdepth outside of wallet's range.") - assert address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID) + assert address_type in self._get_supported_address_types() current_index = self._index_cache[md][address_type] - if index == current_index: + if index == current_index \ + and address_type != FidelityBondMixin.BIP32_TIMELOCK_ID: + #special case for timelocked addresses because for them the + #concept of a "next address" cant be used return self.get_new_script_override_disable(md, address_type) priv, engine = self._get_priv_from_path(path) @@ -1481,9 +1491,16 @@ class BIP32Wallet(BaseWallet): def get_new_script_override_disable(self, mixdepth, address_type): # This is called by get_script_from_path and calls back there. We need to # ensure all conditions match to avoid endless recursion. + index = self.get_index_cache_and_increment(mixdepth, address_type) + return self.get_script_and_update_map(mixdepth, address_type, index) + + def get_index_cache_and_increment(self, mixdepth, address_type): index = self._index_cache[mixdepth][address_type] self._index_cache[mixdepth][address_type] += 1 - path = self.get_path(mixdepth, address_type, index) + return index + + def get_script_and_update_map(self, *args): + path = self.get_path(*args) script = self.get_script_from_path(path) self._script_map[script] = path return script @@ -1571,8 +1588,6 @@ class LegacyWallet(ImportWalletMixin, BIP32Wallet): def _get_bip32_base_path(self): return self._key_ident, 0 - - class BIP32PurposedWallet(BIP32Wallet): """ A class to encapsulate cases like BIP44, 49 and 84, all of which are derivatives @@ -1595,6 +1610,174 @@ class BIP32PurposedWallet(BIP32Wallet): return path[len(self._get_bip32_base_path())] - 2**31 +class FidelityBondMixin(object): + BIP32_TIMELOCK_ID = 2 + BIP32_BURN_ID = 3 + + """ + Explaination of time number + + incrementing time numbers (0, 1, 2, 3, 4... + will produce datetimes + suitable for timelocking (1st january, 1st april, 1st july .... + this greatly reduces the number of possible timelock values + and is helpful for recovery of funds because the wallet can search + the only addresses corresponding to timenumbers which are far fewer + + For example, if TIMENUMBER_UNIT = 2 (i.e. every time number is two months) + then there are 6 timelocks per year so just 600 possible + addresses per century per pubkey. Easily searchable when recovering a + wallet from seed phrase. Therefore the user doesn't need to store any + dates, the seed phrase is sufficent for recovery. + """ + #should be a factor of 12, the number of months in a year + TIMENUMBER_UNIT = 1 + + # all timelocks are 1st of the month at midnight + TIMELOCK_DAY_AND_SHORTER = (1, 0, 0, 0, 0) + TIMELOCK_EPOCH_YEAR = 2020 + TIMELOCK_EPOCH_MONTH = 1 #january + MONTHS_IN_YEAR = 12 + + TIMELOCK_ERA_YEARS = 30 + TIMENUMBERS_PER_PUBKEY = TIMELOCK_ERA_YEARS * MONTHS_IN_YEAR // TIMENUMBER_UNIT + + """ + As each pubkey corresponds to hundreds of addresses, to reduce load the + given gap limit will be reduced by this factor. Also these timelocked + addresses are never handed out to takers so there wont be a problem of + having many used addresses with no transactions on them. + """ + TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR = 6 + + #only one mixdepth will have fidelity bonds in it + FIDELITY_BOND_MIXDEPTH = 0 + + @classmethod + def _time_number_to_timestamp(cls, timenumber): + """ + converts a time number to a unix timestamp + """ + year = cls.TIMELOCK_EPOCH_YEAR + (timenumber*cls.TIMENUMBER_UNIT) // cls.MONTHS_IN_YEAR + month = cls.TIMELOCK_EPOCH_MONTH + (timenumber*cls.TIMENUMBER_UNIT) % cls.MONTHS_IN_YEAR + return timegm(datetime(year, month, *cls.TIMELOCK_DAY_AND_SHORTER).timetuple()) + + @classmethod + def timestamp_to_time_number(cls, timestamp): + """ + converts a datetime object to a time number + """ + dt = datetime.utcfromtimestamp(timestamp) + if (dt.month - cls.TIMELOCK_EPOCH_MONTH) % cls.TIMENUMBER_UNIT != 0: + raise ValueError() + day_and_shorter_tuple = (dt.day, dt.hour, dt.minute, dt.second, dt.microsecond) + if day_and_shorter_tuple != cls.TIMELOCK_DAY_AND_SHORTER: + raise ValueError() + timenumber = (dt.year - cls.TIMELOCK_EPOCH_YEAR)*(cls.MONTHS_IN_YEAR // + cls.TIMENUMBER_UNIT) + ((dt.month - cls.TIMELOCK_EPOCH_MONTH) // cls.TIMENUMBER_UNIT) + if timenumber < 0 or timenumber > cls.TIMENUMBERS_PER_PUBKEY: + raise ValueError("datetime out of range") + return timenumber + + @classmethod + def _is_timelocked_path(cls, path): + return len(path) > 4 and path[4] == cls.BIP32_TIMELOCK_ID + + def get_xpub_from_fidelity_bond_master_pub_key(cls, mpk): + if mpk.startswith(cls._BIP32_PUBKEY_PREFIX): + return mpk[len(cls._BIP32_PUBKEY_PREFIX):] + else: + return False + + def _get_key_ident(self): + first_path = self.get_path(0, 0) + priv, engine = self._get_priv_from_path(first_path) + pub = engine.privkey_to_pubkey(priv) + return sha256(sha256(pub).digest()).digest()[:3] + + def _populate_script_map(self): + super(FidelityBondMixin, self)._populate_script_map() + for md in self._index_cache: + address_type = self.BIP32_TIMELOCK_ID + for i in range(self._index_cache[md][address_type]): + for timenumber in range(self.TIMENUMBERS_PER_PUBKEY): + path = self.get_path(md, address_type, i, timenumber) + script = self.get_script_from_path(path) + self._script_map[script] = path + + @classmethod + def _get_supported_address_types(cls): + return (cls.BIP32_EXT_ID, cls.BIP32_INT_ID, cls.BIP32_TIMELOCK_ID, cls.BIP32_BURN_ID) + + def _get_priv_from_path(self, path): + if self._is_timelocked_path(path): + key_path = path[:-1] + locktime = path[-1] + engine = ENGINES[TYPE_TIMELOCK_P2WSH] + privkey = engine.derive_bip32_privkey(self._master_key, key_path) + return (privkey, locktime), engine + else: + return super(FidelityBondMixin, self)._get_priv_from_path(path) + + def get_path(self, mixdepth=None, address_type=None, index=None, timenumber=None): + if address_type == None or address_type in (self.BIP32_EXT_ID, self.BIP32_INT_ID, + self.BIP32_BURN_ID) or index == None: + return super(FidelityBondMixin, self).get_path(mixdepth, address_type, index) + elif address_type == self.BIP32_TIMELOCK_ID: + assert timenumber != None + timestamp = self._time_number_to_timestamp(timenumber) + return tuple(chain(self._get_bip32_export_path(mixdepth, address_type), + (index, timestamp))) + else: + assert 0 + + """ + We define a new serialization of the bip32 path to include bip65 timelock addresses + Previously it was m/44'/1'/0'/3/0 + For a timelocked address it will be m/44'/1'/0'/3/0:13245432 + The timelock will be in unix format and added to the end with a colon ":" character + refering to the pubkey plus the timelock value which together are needed to create the address + """ + def get_path_repr(self, path): + if self._is_timelocked_path(path) and len(path) == 7: + return super(FidelityBondMixin, self).get_path_repr(path[:-1]) +\ + ":" + str(path[-1]) + else: + return super(FidelityBondMixin, self).get_path_repr(path) + + def path_repr_to_path(self, pathstr): + if pathstr.find(":") == -1: + return super(FidelityBondMixin, self).path_repr_to_path(pathstr) + else: + colon_chunks = pathstr.split(":") + if len(colon_chunks) != 2: + raise WalletError("Not a valid wallet timelock path: {}".format(pathstr)) + return tuple(chain( + super(FidelityBondMixin, self).path_repr_to_path(colon_chunks[0]), + (int(colon_chunks[1]),) + )) + + def get_details(self, path): + if self._is_timelocked_path(path): + return self._get_mixdepth_from_path(path), path[-3], path[-2] + else: + return super(FidelityBondMixin, self).get_details(path) + + def _get_default_used_indices(self): + return {x: [0, 0, 0, 0] for x in range(self.max_mixdepth + 1)} + + def get_script(self, mixdepth, address_type, index, timenumber=None): + path = self.get_path(mixdepth, address_type, index, timenumber) + return self.get_script_from_path(path) + + def get_addr(self, mixdepth, address_type, index, timenumber=None): + script = self.get_script(mixdepth, address_type, index, timenumber) + return self.script_to_addr(script) + +#class FidelityBondWatchonlyWallet(ImportWalletMixin, BIP39WalletMixin, FidelityBondMixin): + + + class BIP49Wallet(BIP32PurposedWallet): _PURPOSE = 2**31 + 49 _ENGINE = ENGINES[TYPE_P2SH_P2WPKH] @@ -1609,8 +1792,12 @@ class SegwitLegacyWallet(ImportWalletMixin, BIP39WalletMixin, BIP49Wallet): class SegwitWallet(ImportWalletMixin, BIP39WalletMixin, BIP84Wallet): TYPE = TYPE_P2WPKH +class SegwitLegacyWalletFidelityBonds(FidelityBondMixin, SegwitLegacyWallet): + TYPE = TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + WALLET_IMPLEMENTATIONS = { LegacyWallet.TYPE: LegacyWallet, SegwitLegacyWallet.TYPE: SegwitLegacyWallet, - SegwitWallet.TYPE: SegwitWallet + SegwitWallet.TYPE: SegwitWallet, + SegwitLegacyWalletFidelityBonds.TYPE: SegwitLegacyWalletFidelityBonds } diff --git a/jmclient/jmclient/wallet_service.py b/jmclient/jmclient/wallet_service.py index 1d75ae9..c21fc70 100644 --- a/jmclient/jmclient/wallet_service.py +++ b/jmclient/jmclient/wallet_service.py @@ -15,6 +15,7 @@ from jmclient.configure import jm_single, get_log from jmclient.output import fmt_tx_data from jmclient.blockchaininterface import (INF_HEIGHT, BitcoinCoreInterface, BitcoinCoreNoHistoryInterface) +from jmclient.wallet import FidelityBondMixin from jmbase.support import jmprint, EXIT_SUCCESS """Wallet service @@ -735,6 +736,22 @@ class WalletService(Service): for path in self.yield_imported_paths(md): addresses.add(self.get_address_from_path(path)) + if isinstance(self.wallet, FidelityBondMixin): + md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + saved_indices[md] += [0] + next_unused = self.get_next_unused_index(md, address_type) + for index in range(next_unused): + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + addresses.add(self.get_addr(md, address_type, index, timenumber)) + for index in range(self.gap_limit // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR): + index += next_unused + assert self.wallet.get_index_cache_and_increment(md, address_type) == index + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + self.wallet.get_script_and_update_map(md, address_type, index, timenumber) + addresses.add(self.get_addr(md, address_type, index, timenumber)) + self.wallet.set_next_index(md, address_type, next_unused) + return addresses, saved_indices def collect_addresses_gap(self, gap_limit=None): @@ -748,6 +765,17 @@ class WalletService(Service): addresses.add(self.get_new_addr(md, address_type)) self.set_next_index(md, address_type, old_next) + if isinstance(self.wallet, FidelityBondMixin): + md = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + old_next = self.get_next_unused_index(md, address_type) + for ii in range(gap_limit // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR): + index = self.wallet.get_index_cache_and_increment(md, address_type) + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + self.wallet.get_script_and_update_map(md, address_type, index, timenumber) + addresses.add(self.get_addr(md, address_type, index, timenumber)) + self.set_next_index(md, address_type, old_next) + return addresses def get_external_addr(self, mixdepth): diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index 6a1d4a7..7ff37cc 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -5,6 +5,7 @@ import sys import sqlite3 import binascii from datetime import datetime +from calendar import timegm from optparse import OptionParser from numbers import Integral from collections import Counter @@ -12,12 +13,13 @@ from itertools import islice from jmclient import (get_network, WALLET_IMPLEMENTATIONS, Storage, podle, jm_single, BitcoinCoreInterface, WalletError, VolatileStorage, StoragePasswordError, is_segwit_mode, SegwitLegacyWallet, - LegacyWallet, SegwitWallet, is_native_segwit_mode, load_program_config, - add_base_options, check_regtest) + LegacyWallet, SegwitWallet, FidelityBondMixin, is_native_segwit_mode, + load_program_config, add_base_options, check_regtest) from jmclient.wallet_service import WalletService from jmbase.support import get_password, jmprint, EXIT_FAILURE, EXIT_ARGERROR -from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH +from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, \ + TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS from .output import fmt_utxo import jmbitcoin as btc @@ -42,8 +44,9 @@ def get_wallettool_parser(): '(dumpprivkey) Export a single private key, specify an hd wallet path\n' '(signmessage) Sign a message with the private key from an address in \n' 'the wallet. Use with -H and specify an HD wallet path for the address.\n' - '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.') - parser = OptionParser(usage='usage: %prog [options] [wallet file] [method]', + '(freeze) Freeze or un-freeze a specific utxo. Specify mixdepth with -m.\n' + '(gettimelockaddress) Obtain a timelocked address. Argument is locktime value as yyyy-mm. For example `2021-03`') + parser = OptionParser(usage='usage: %prog [options] [wallet file] [method] [args..]', description=description) add_base_options(parser) parser.add_option('-p', @@ -167,7 +170,9 @@ class WalletViewEntry(WalletViewBase): super(WalletViewEntry, self).__init__(wallet_path_repr, serclass=serclass, custom_separator=custom_separator) self.account = account - assert address_type in [0, 1, -1] + assert address_type in [SegwitWallet.BIP32_EXT_ID, + SegwitWallet.BIP32_INT_ID, -1, FidelityBondMixin.BIP32_TIMELOCK_ID, + FidelityBondMixin.BIP32_BURN_ID] self.address_type = address_type assert isinstance(aindex, Integral) assert aindex >= 0 @@ -219,7 +224,9 @@ class WalletViewBranch(WalletViewBase): serclass=serclass, custom_separator=custom_separator) self.account = account - assert address_type in [0, 1, -1] + assert address_type in [SegwitWallet.BIP32_EXT_ID, + SegwitWallet.BIP32_INT_ID, -1, FidelityBondMixin.BIP32_TIMELOCK_ID, + FidelityBondMixin.BIP32_BURN_ID] self.address_type = address_type if xpub: assert xpub.startswith('xpub') or xpub.startswith('tpub') @@ -441,11 +448,42 @@ def wallet_display(wallet_service, showprivkey, displayall=False, entrylist.append(WalletViewEntry( wallet_service.get_path_repr(path), m, address_type, k, addr, [balance, balance], priv=privkey, used=used)) - wallet_service.set_next_index(m, address_type, unused_index) path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) branchlist.append(WalletViewBranch(path, m, address_type, entrylist, xpub=xpub_key)) + + if m == FidelityBondMixin.FIDELITY_BOND_MIXDEPTH and \ + isinstance(wallet_service.wallet, FidelityBondMixin): + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + unused_index = wallet_service.get_next_unused_index(m, address_type) + timelocked_gaplimit = (wallet_service.wallet.gap_limit + // FidelityBondMixin.TIMELOCK_GAP_LIMIT_REDUCTION_FACTOR) + entrylist = [] + for k in range(unused_index + timelocked_gaplimit): + for timenumber in range(FidelityBondMixin.TIMENUMBERS_PER_PUBKEY): + path = wallet_service.get_path(m, address_type, k, timenumber) + addr = wallet_service.get_address_from_path(path) + timelock = datetime.utcfromtimestamp(path[-1]) + + balance = sum([utxodata["value"] for utxo, utxodata in + iteritems(utxos[m]) if path == utxodata["path"]]) + status = timelock.strftime("%Y-%m-%d") + " [" + ( + "LOCKED" if datetime.now() < timelock else "UNLOCKED") + "]" + privkey = "" + if showprivkey: + privkey = wallet_service.get_wif_path(path) + if displayall or balance > 0: + entrylist.append(WalletViewEntry( + wallet_service.get_path_repr(path), m, address_type, k, + addr, [balance, balance], priv=privkey, used=status)) + + #TODO fidelity bond master pub key is this, although it should include burner too + xpub_key = wallet_service.get_bip32_pub_export(m, address_type) + path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) + branchlist.append(WalletViewBranch(path, m, address_type, entrylist, + xpub=xpub_key)) + ipb = get_imported_privkey_branch(wallet_service, m, showprivkey) if ipb: branchlist.append(ipb) @@ -499,11 +537,19 @@ def cli_get_mnemonic_extension(): "info") return input("Enter mnemonic extension: ") +def cli_do_support_fidelity_bonds(): + uin = input("Would you like this wallet to support fidelity bonds? " + "write 'n' if you don't know what this is (y/n): ") + if len(uin) == 0 or uin[0] != 'y': + jmprint("Not supporting fidelity bonds", "info") + return False + else: + return True def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, display_seed_callback, enter_seed_callback, enter_wallet_password_callback, enter_wallet_file_name_callback, enter_if_use_seed_extension, - enter_seed_extension_callback, mixdepth=DEFAULT_MIXDEPTH): + enter_seed_extension_callback, enter_do_support_fidelity_bonds, mixdepth=DEFAULT_MIXDEPTH): entropy = None mnemonic_extension = None if method == "generate": @@ -537,7 +583,10 @@ def wallet_generate_recover_bip39(method, walletspath, default_wallet_name, wallet_name = default_wallet_name wallet_path = os.path.join(walletspath, wallet_name) - wallet = create_wallet(wallet_path, password, mixdepth, + support_fidelity_bonds = enter_do_support_fidelity_bonds() + wallet_cls = get_wallet_cls(get_configured_wallet_type(support_fidelity_bonds)) + + wallet = create_wallet(wallet_path, password, mixdepth, wallet_cls, entropy=entropy, entropy_extension=mnemonic_extension) mnemonic, mnext = wallet.get_mnemonic_words() @@ -555,7 +604,7 @@ def wallet_generate_recover(method, walletspath, default_wallet_name, cli_display_user_words, cli_user_mnemonic_entry, cli_get_wallet_passphrase_check, cli_get_wallet_file_name, cli_do_use_mnemonic_extension, cli_get_mnemonic_extension, - mixdepth=mixdepth) + cli_do_support_fidelity_bonds, mixdepth=mixdepth) entropy = None if method == 'recover': @@ -1035,29 +1084,54 @@ def wallet_freezeutxo(wallet, md, display_callback=None, info_callback=None): .format(fmt_utxo((txid, index)))) return "Done" -def get_wallet_type(): + + +def wallet_gettimelockaddress(wallet_service, locktime_string): + if not isinstance(wallet_service.wallet, FidelityBondMixin): + jmprint("Error: not a fidelity bond wallet", "error") + return "" + + m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + index = wallet_service.get_next_unused_index(m, address_type) + lock_datetime = datetime.strptime(locktime_string, "%Y-%m") + timenumber = FidelityBondMixin.timestamp_to_time_number(timegm( + lock_datetime.timetuple())) + + path = wallet_service.get_path(m, address_type, index, timenumber) + jmprint("path = " + wallet_service.get_path_repr(path), "info") + jmprint("Coins sent to this address will be not be spendable until " + + lock_datetime.strftime("%B %Y") + ". Full date: " + + str(lock_datetime)) + addr = wallet_service.get_address_from_path(path) + return addr + +def get_configured_wallet_type(support_fidelity_bonds): + configured_type = TYPE_P2PKH if is_segwit_mode(): if is_native_segwit_mode(): - return TYPE_P2WPKH - return TYPE_P2SH_P2WPKH - return TYPE_P2PKH + configured_type = TYPE_P2WPKH + else: + configured_type = TYPE_P2SH_P2WPKH + if not support_fidelity_bonds: + return configured_type -def get_wallet_cls(wtype=None): - if wtype is None: - wtype = get_wallet_type() + if configured_type == TYPE_P2SH_P2WPKH: + return TYPE_SEGWIT_LEGACY_WALLET_FIDELITY_BONDS + else: + raise ValueError("Fidelity bonds not supported with the configured " + "options of segwit and native. Edit joinmarket.cfg") +def get_wallet_cls(wtype): cls = WALLET_IMPLEMENTATIONS.get(wtype) - if not cls: raise WalletError("No wallet implementation found for type {}." "".format(wtype)) return cls - -def create_wallet(path, password, max_mixdepth, wallet_cls=None, **kwargs): +def create_wallet(path, password, max_mixdepth, wallet_cls, **kwargs): storage = Storage(path, password, create=True) - wallet_cls = wallet_cls or get_wallet_cls() wallet_cls.initialize(storage, get_network(), max_mixdepth=max_mixdepth, **kwargs) storage.save() @@ -1195,11 +1269,12 @@ def wallet_tool_main(wallet_root_path): wallet_root_path = os.path.join(jm_single().datadir, wallet_root_path) noseed_methods = ['generate', 'recover'] methods = ['display', 'displayall', 'summary', 'showseed', 'importprivkey', - 'history', 'showutxos', 'freeze'] + 'history', 'showutxos', 'freeze', 'gettimelockaddress'] methods.extend(noseed_methods) noscan_methods = ['showseed', 'importprivkey', 'dumpprivkey', 'signmessage'] readonly_methods = ['display', 'displayall', 'summary', 'showseed', - 'history', 'showutxos', 'dumpprivkey', 'signmessage'] + 'history', 'showutxos', 'dumpprivkey', 'signmessage', + 'gettimelockaddress'] if len(args) < 1: parser.error('Needs a wallet file or method') @@ -1273,9 +1348,17 @@ def wallet_tool_main(wallet_root_path): map_key_type(options.key_type)) return "Key import completed." elif method == "signmessage": + if len(args) < 3: + jmprint('Must provide message to sign', "error") + sys.exit(EXIT_ARGERROR) return wallet_signmessage(wallet_service, options.hd_path, args[2]) elif method == "freeze": return wallet_freezeutxo(wallet_service, options.mixdepth) + elif method == "gettimelockaddress": + if len(args) < 3: + jmprint('Must have locktime value yyyy-mm. For example 2021-03', "error") + sys.exit(EXIT_ARGERROR) + return wallet_gettimelockaddress(wallet_service, args[2]) else: parser.error("Unknown wallet-tool method: " + method) sys.exit(EXIT_ARGERROR) diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index a3bea2b..6764f88 100644 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -1808,7 +1808,8 @@ class JMMainWindow(QMainWindow): enter_wallet_password_callback=self.getPassword, enter_wallet_file_name_callback=self.getWalletFileName, enter_if_use_seed_extension=self.promptUseMnemonicExtension, - enter_seed_extension_callback=self.promptInputMnemonicExtension) + enter_seed_extension_callback=self.promptInputMnemonicExtension, + enter_do_support_fidelity_bonds=lambda: False) if not success: JMQtMessageBox(self, "Failed to create new wallet file.",