diff --git a/jmclient/jmclient/__init__.py b/jmclient/jmclient/__init__.py index f982920..f4dd264 100644 --- a/jmclient/jmclient/__init__.py +++ b/jmclient/jmclient/__init__.py @@ -13,7 +13,8 @@ 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, FidelityBondMixin, FidelityBondWatchonlyWallet, + SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, + FidelityBondWatchonlyWallet, SegwitLegacyWalletFidelityBonds, UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) @@ -49,7 +50,7 @@ from .cli_options import (add_base_options, add_common_options, from .wallet_utils import ( wallet_tool_main, wallet_generate_recover_bip39, open_wallet, open_test_wallet_maybe, create_wallet, get_wallet_cls, get_wallet_path, - wallet_display, get_utxos_enabled_disabled) + wallet_display, get_utxos_enabled_disabled, wallet_gettimelockaddress) from .wallet_service import WalletService from .maker import Maker, P2EPMaker from .yieldgenerator import YieldGenerator, YieldGeneratorBasic, ygmain diff --git a/jmclient/jmclient/wallet.py b/jmclient/jmclient/wallet.py index 37a62fc..40b19ec 100644 --- a/jmclient/jmclient/wallet.py +++ b/jmclient/jmclient/wallet.py @@ -1672,6 +1672,8 @@ class FidelityBondMixin(object): """ converts a time number to a unix timestamp """ + if not 0 <= timenumber < cls.TIMENUMBERS_PER_PUBKEY: + raise ValueError() 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()) diff --git a/jmclient/jmclient/wallet_utils.py b/jmclient/jmclient/wallet_utils.py index c972709..054dea1 100644 --- a/jmclient/jmclient/wallet_utils.py +++ b/jmclient/jmclient/wallet_utils.py @@ -1135,24 +1135,24 @@ def wallet_freezeutxo(wallet, md, display_callback=None, info_callback=None): -def wallet_gettimelockaddress(wallet_service, locktime_string): - if not isinstance(wallet_service.wallet, FidelityBondMixin): +def wallet_gettimelockaddress(wallet, locktime_string): + if not isinstance(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) + index = wallet.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") + path = wallet.get_path(m, address_type, index, timenumber) + jmprint("path = " + wallet.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) + addr = wallet.get_address_from_path(path) return addr def wallet_addtxoutproof(wallet_service, hdpath, txoutproof): @@ -1457,7 +1457,7 @@ def wallet_tool_main(wallet_root_path): 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]) + return wallet_gettimelockaddress(wallet_service.wallet, args[2]) elif method == "addtxoutproof": if len(args) < 3: jmprint('Must have txout proof, which is the output of Bitcoin ' diff --git a/jmclient/test/commontest.py b/jmclient/test/commontest.py index 463c97b..55395e0 100644 --- a/jmclient/test/commontest.py +++ b/jmclient/test/commontest.py @@ -209,3 +209,13 @@ def interact(process, inputs, expected): for i, inp in enumerate(inputs): process.expect(expected[i]) process.sendline(inp) + +def ensure_bip65_activated(): + #on regtest bip65 activates on height 1351 + #https://github.com/bitcoin/bitcoin/blob/1d1f8bbf57118e01904448108a104e20f50d2544/src/chainparams.cpp#L262 + BIP65Height = 1351 + current_height = jm_single().bc_interface.rpc("getblockchaininfo", [])["blocks"] + until_bip65_activation = BIP65Height - current_height + 1 + if until_bip65_activation > 0: + jm_single().bc_interface.tick_forward_chain(until_bip65_activation) + diff --git a/jmclient/test/test_tx_creation.py b/jmclient/test/test_tx_creation.py index bb30803..d251b43 100644 --- a/jmclient/test/test_tx_creation.py +++ b/jmclient/test/test_tx_creation.py @@ -5,9 +5,8 @@ network to check validity.''' import time import binascii import struct -from commontest import make_wallets, make_sign_and_push from binascii import unhexlify ->>>>>>> c158543... only allow pubkey in bytes for mk_freeze_script() +from commontest import make_wallets, make_sign_and_push, ensure_bip65_activated import jmbitcoin as bitcoin import pytest @@ -280,15 +279,6 @@ def test_spend_p2wsh(setup_tx_creation): txid = jm_single().bc_interface.pushtx(tx) assert txid -def ensure_bip65_activated(): - #on regtest bip65 activates on height 1351 - #https://github.com/bitcoin/bitcoin/blob/1d1f8bbf57118e01904448108a104e20f50d2544/src/chainparams.cpp#L262 - BIP65Height = 1351 - current_height = jm_single().bc_interface.rpc("getblockchaininfo", [])["blocks"] - until_bip65_activation = BIP65Height - current_height + 1 - if until_bip65_activation > 0: - jm_single().bc_interface.tick_forward_chain(until_bip65_activation) - def test_spend_freeze_script(setup_tx_creation): ensure_bip65_activated() diff --git a/jmclient/test/test_wallet.py b/jmclient/test/test_wallet.py index f424d9f..058c810 100644 --- a/jmclient/test/test_wallet.py +++ b/jmclient/test/test_wallet.py @@ -6,12 +6,13 @@ from binascii import hexlify, unhexlify import pytest import jmbitcoin as btc -from commontest import binarize_tx +from commontest import binarize_tx, ensure_bip65_activated from jmbase import get_log from jmclient import load_test_config, jm_single, \ SegwitLegacyWallet,BIP32Wallet, BIP49Wallet, LegacyWallet,\ VolatileStorage, get_network, cryptoengine, WalletError,\ - SegwitWallet, WalletService + SegwitWallet, WalletService, SegwitLegacyWalletFidelityBonds,\ + FidelityBondMixin, FidelityBondWatchonlyWallet, wallet_gettimelockaddress from test_blockchaininterface import sync_test_wallet testdir = os.path.dirname(os.path.realpath(__file__)) @@ -239,6 +240,72 @@ def test_bip32_addresses_p2sh_p2wpkh(setup_wallet, mixdepth, internal, index, ad assert wif == wallet.get_wif(mixdepth, internal, index) assert address == wallet.get_addr(mixdepth, internal, index) +@pytest.mark.parametrize('index,timenumber,address,wif', [ + [0, 0, 'bcrt1qndcqwedwa4lu77ryqpvp738d6p034a2fv8mufw3pw5smfcn39sgqpesn76', 'cST4g5R3mKp44K4J8PRVyys4XJu6EFavZyssq67PJKCnbhjdEdBY'], + [0, 50, 'bcrt1q73zhrfcu0ttkk4er9esrmvnpl6wpzhny5aly97jj9nw52agf8ncqjv8rda', 'cST4g5R3mKp44K4J8PRVyys4XJu6EFavZyssq67PJKCnbhjdEdBY'], + [5, 0, 'bcrt1qz5208jdm6399ja309ra28d0a34qlt0859u77uxc94v5mgk7auhtssau4pw', 'cRnUaBYTmyZURPe72YCrtvgxpBMvLKPZaCoXvKuWRPMryeJeAZx2'], + [9, 1, 'bcrt1qa7pd6qnadpmlm29vtvqnykalc34tr33eclaz7eeqal59n4gwr28qwnka2r', 'cQCxEPCWMwXVB16zCikDBTXMUccx6ioHQipPhYEp1euihkJUafyD'] +]) +def test_bip32_timelocked_addresses(setup_wallet, index, timenumber, address, wif): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=1) + wallet = SegwitLegacyWalletFidelityBonds(storage) + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + + #wallet needs to know about the script beforehand + wallet.get_script_and_update_map(mixdepth, address_type, index, timenumber) + + assert address == wallet.get_addr(mixdepth, address_type, index, timenumber) + assert wif == wallet.get_wif_path(wallet.get_path(mixdepth, address_type, index, timenumber)) + +@pytest.mark.parametrize('timenumber,locktime_string', [ + [0, "2020-01"], + [20, "2021-09"], + [100, "2028-05"], + [150, "2032-07"], + [350, "2049-03"] +]) +def test_gettimelockaddress_method(setup_wallet, timenumber, locktime_string): + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + m = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_TIMELOCK_ID + index = wallet.get_next_unused_index(m, address_type) + script = wallet.get_script_and_update_map(m, address_type, index, + timenumber) + addr = wallet.script_to_addr(script) + + addr_from_method = wallet_gettimelockaddress(wallet, locktime_string) + + assert addr == addr_from_method + +@pytest.mark.parametrize('index,wif', [ + [0, 'cMg9eH3fW2JDSyggvXucjmECRwiheCMDo2Qik8y1keeYaxynzrYa'], + [9, 'cURA1Qgxhd7QnhhwxCnCHD4pZddVrJdu2BkTdzNaTp9owRSkUvPy'], + [50, 'cRTaHZ1eezb8s6xsT2V7EAevYToQMi7cxQD9vgFZzaJZDfhMhf3c'] +]) +def test_bip32_burn_keys(setup_wallet, index, wif): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + + entropy = unhexlify('2e0339ba89b4a1272cdf78b27ee62669ee01992a59e836e2807051be128ca817') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize( + storage, get_network(), entropy=entropy, max_mixdepth=1) + wallet = SegwitLegacyWalletFidelityBonds(storage) + mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH + address_type = FidelityBondMixin.BIP32_BURN_ID + + #advance index_cache enough + wallet.set_next_index(mixdepth, address_type, index, force=True) + + assert wif == wallet.get_wif_path(wallet.get_path(mixdepth, address_type, index)) def test_import_key(setup_wallet): jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') @@ -331,6 +398,29 @@ def test_signing_simple(setup_wallet, wallet_cls, type_check): txout = jm_single().bc_interface.pushtx(btc.serialize(tx)) assert txout +def test_timelocked_output_signing(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + ensure_bip65_activated() + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + index = 0 + timenumber = 0 + script = wallet.get_script_and_update_map( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH, + FidelityBondMixin.BIP32_TIMELOCK_ID, index, timenumber) + utxo = fund_wallet_addr(wallet, wallet.script_to_addr(script)) + timestamp = wallet._time_number_to_timestamp(timenumber) + + tx = btc.deserialize(btc.mktx(['{}:{}'.format( + hexlify(utxo[0]).decode('ascii'), utxo[1])], + [btc.p2sh_scriptaddr(b"\x00",magicbyte=196) + ':' + str(10**8 - 9000)], + locktime=timestamp+1)) + tx = wallet.sign_tx(tx, {0: (script, 10**8)}) + txout = jm_single().bc_interface.pushtx(btc.serialize(tx)) + assert txout + def test_get_bbm(setup_wallet): jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') amount = 10**8 @@ -537,6 +627,37 @@ def test_path_repr_imported(setup_wallet): assert path_new == path +@pytest.mark.parametrize('timenumber,timestamp', [ + [0, 1577836800], + [50, 1709251200], + [300, 2366841600], + [400, None], #too far in the future + [-1, None] #before epoch +]) +def test_timenumber_to_timestamp(setup_wallet, timenumber, timestamp): + try: + implied_timestamp = FidelityBondMixin._time_number_to_timestamp( + timenumber) + assert implied_timestamp == timestamp + except ValueError: + #None means the timenumber is intentionally invalid + assert timestamp == None + +@pytest.mark.parametrize('timestamp,timenumber', [ + [1577836800, 0], + [1709251200, 50], + [2366841600, 300], + [1577836801, None], #not exactly midnight on first of month + [2629670400, None], #too far in future + [1575158400, None] #before epoch +]) +def test_timestamp_to_timenumber(setup_wallet, timestamp, timenumber): + try: + implied_timenumber = FidelityBondMixin.timestamp_to_time_number( + timestamp) + assert implied_timenumber == timenumber + except ValueError: + assert timenumber == None def test_wrong_wallet_cls(setup_wallet): storage = VolatileStorage() @@ -658,6 +779,42 @@ def test_wallet_mixdepth_decrease(setup_wallet): # because we explicitly ask for a specific mixdepth assert utxo in new_wallet.select_utxos_(max_mixdepth, 10**7) +def test_watchonly_wallet(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + SegwitLegacyWalletFidelityBonds.initialize(storage, get_network()) + wallet = SegwitLegacyWalletFidelityBonds(storage) + + paths = [ + "m/49'/1'/0'/0/0", + "m/49'/1'/0'/1/0", + "m/49'/1'/0'/2/0:1577836800", + "m/49'/1'/0'/2/0:2314051200" + ] + burn_path = "m/49'/1'/0'/3/0" + + scripts = [wallet.get_script_from_path(wallet.path_repr_to_path(path)) + for path in paths] + privkey, engine = wallet._get_priv_from_path(wallet.path_repr_to_path(burn_path)) + burn_pubkey = engine.privkey_to_pubkey(privkey) + + master_pub_key = wallet.get_bip32_pub_export( + FidelityBondMixin.FIDELITY_BOND_MIXDEPTH) + watchonly_storage = VolatileStorage() + entropy = FidelityBondMixin.get_xpub_from_fidelity_bond_master_pub_key( + master_pub_key).encode() + FidelityBondWatchonlyWallet.initialize(watchonly_storage, get_network(), + entropy=entropy) + watchonly_wallet = FidelityBondWatchonlyWallet(watchonly_storage) + + watchonly_scripts = [watchonly_wallet.get_script_from_path( + watchonly_wallet.path_repr_to_path(path)) for path in paths] + privkey, engine = wallet._get_priv_from_path(wallet.path_repr_to_path(burn_path)) + watchonly_burn_pubkey = engine.privkey_to_pubkey(privkey) + + for script, watchonly_script in zip(scripts, watchonly_scripts): + assert script == watchonly_script + assert burn_pubkey == watchonly_burn_pubkey @pytest.fixture(scope='module') def setup_wallet():