diff --git a/lbry/lbry/extras/daemon/Daemon.py b/lbry/lbry/extras/daemon/Daemon.py index dc1a10f06..ab1259ee4 100644 --- a/lbry/lbry/extras/daemon/Daemon.py +++ b/lbry/lbry/extras/daemon/Daemon.py @@ -999,7 +999,7 @@ class Daemon(metaclass=JSONRPCServerType): Preferences management. """ - def jsonrpc_preference_get(self, key=None, account_id=None, wallet_id=None): + def jsonrpc_preference_get(self, key=None, wallet_id=None): """ Get preference value for key or all values if not key is passed in. @@ -1008,21 +1008,19 @@ class Daemon(metaclass=JSONRPCServerType): Options: --key= : (str) key associated with value - --account_id= : (str) id of the account containing value --wallet_id= : (str) restrict operation to specific wallet Returns: (dict) Dictionary of preference(s) """ wallet = self.wallet_manager.get_wallet_or_default(wallet_id) - account = wallet.get_account_or_default(account_id) if key: - if key in account.preferences: - return {key: account.preferences[key]} + if key in wallet.preferences: + return {key: wallet.preferences[key]} return - return account.preferences + return wallet.preferences.to_dict_without_ts() - def jsonrpc_preference_set(self, key, value, account_id=None, wallet_id=None): + def jsonrpc_preference_set(self, key, value, wallet_id=None): """ Set preferences @@ -1032,18 +1030,15 @@ class Daemon(metaclass=JSONRPCServerType): Options: --key= : (str) key associated with value --value= : (str) key associated with value - --account_id= : (str) id of the account containing value --wallet_id= : (str) restrict operation to specific wallet Returns: (dict) Dictionary with key/value of new preference """ wallet = self.wallet_manager.get_wallet_or_default(wallet_id) - account = wallet.get_account_or_default(account_id) if value and isinstance(value, str) and value[0] in ('[', '{'): value = json.loads(value) - account.preferences[key] = value - account.modified_on = time.time() + wallet.preferences[key] = value wallet.save() return {key: value} @@ -1342,7 +1337,7 @@ class Daemon(metaclass=JSONRPCServerType): account.name = new_name change_made = True - if default: + if default and wallet.default_account != account: wallet.accounts.remove(account) wallet.accounts.insert(0, account) change_made = True @@ -1578,14 +1573,14 @@ class Daemon(metaclass=JSONRPCServerType): return hexlify(wallet.hash).decode() @requires("wallet") - def jsonrpc_sync_apply(self, password, data=None, encrypt_password=None, wallet_id=None): + async def jsonrpc_sync_apply(self, password, data=None, encrypt_password=None, wallet_id=None, blocking=False): """ Apply incoming synchronization data, if provided, and then produce a sync hash and an encrypted wallet. Usage: sync_apply [--data=] [--encrypt-password=] - [--wallet_id=] + [--wallet_id=] [--blocking] Options: --password= : (str) password to decrypt incoming and encrypt outgoing data @@ -1593,6 +1588,7 @@ class Daemon(metaclass=JSONRPCServerType): --encrypt-password= : (str) password to encrypt outgoing data if different from the decrypt password, used during password changes --wallet_id= : (str) wallet being sync'ed + --blocking : (bool) wait until any new accounts have sync'ed Returns: (map) sync hash and data @@ -1600,23 +1596,16 @@ class Daemon(metaclass=JSONRPCServerType): """ wallet = self.wallet_manager.get_wallet_or_default(wallet_id) if data is not None: - decrypted_data = Wallet.unpack(password, data) - for account_data in decrypted_data['accounts']: - _, _, pubkey = LBCAccount.keys_from_dict(self.ledger, account_data) - account_id = pubkey.address - local_match = None - for local_account in wallet.accounts: - if account_id == local_account.id: - local_match = local_account - break - if local_match is not None: - local_match.apply(account_data) + added_accounts = wallet.merge(self.wallet_manager, password, data) + if added_accounts and self.ledger.network.is_connected: + if blocking: + await asyncio.wait([ + a.ledger.subscribe_account(a) for a in added_accounts + ]) else: - new_account = LBCAccount.from_dict(self.ledger, wallet, account_data) - if self.ledger.network.is_connected: + for new_account in added_accounts: asyncio.create_task(self.ledger.subscribe_account(new_account)) wallet.save() - encrypted = wallet.pack(encrypt_password or password) return { 'hash': self.jsonrpc_sync_hash(wallet_id), diff --git a/lbry/lbry/testcase.py b/lbry/lbry/testcase.py index 9bf82cfe7..0e13971a9 100644 --- a/lbry/lbry/testcase.py +++ b/lbry/lbry/testcase.py @@ -151,11 +151,11 @@ class CommandTestCase(IntegrationTestCase): wallet_node.manager.old_db = daemon.storage return daemon - async def confirm_tx(self, txid): + async def confirm_tx(self, txid, ledger=None): """ Wait for tx to be in mempool, then generate a block, wait for tx to be in a block. """ - await self.on_transaction_id(txid) + await self.on_transaction_id(txid, ledger) await self.generate(1) - await self.on_transaction_id(txid) + await self.on_transaction_id(txid, ledger) return txid async def on_transaction_dict(self, tx): diff --git a/lbry/lbry/wallet/account.py b/lbry/lbry/wallet/account.py index aa4f9e3ec..623d8dd35 100644 --- a/lbry/lbry/wallet/account.py +++ b/lbry/lbry/wallet/account.py @@ -28,7 +28,6 @@ class Account(BaseAccount): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.channel_keys = {} - self.preferences = {} @property def hash(self) -> bytes: @@ -37,10 +36,9 @@ class Account(BaseAccount): h.update(cert.encode()) return h.digest() - def apply(self, d: dict): - super().apply(d) + def merge(self, d: dict): + super().merge(d) self.channel_keys.update(d.get('certificates', {})) - self.preferences.update(d.get('preferences', {})) def add_channel_private_key(self, private_key): public_key_bytes = private_key.get_verifying_key().to_der() @@ -120,22 +118,17 @@ class Account(BaseAccount): def from_dict(cls, ledger, wallet, d: dict) -> 'Account': account = super().from_dict(ledger, wallet, d) account.channel_keys = d.get('certificates', {}) - account.preferences = d.get('preferences', {}) return account - def to_dict(self, include_channel_keys=True, include_preferences=True): + def to_dict(self, include_channel_keys=True): d = super().to_dict() if include_channel_keys: d['certificates'] = self.channel_keys - if include_preferences and self.preferences: - d['preferences'] = self.preferences return d async def get_details(self, **kwargs): details = await super().get_details(**kwargs) details['certificates'] = len(self.channel_keys) - if self.preferences: - details['preferences'] = self.preferences return details def get_transaction_history(self, **constraints): diff --git a/lbry/tests/integration/test_sync.py b/lbry/tests/integration/test_sync.py index cae210dcf..63bef8340 100644 --- a/lbry/tests/integration/test_sync.py +++ b/lbry/tests/integration/test_sync.py @@ -1,114 +1,66 @@ -from unittest import mock - -from torba.orchstr8.node import WalletNode, SPVNode -from torba.testcase import AsyncioTestCase - -from lbry.conf import Config -from lbry.wallet import LbryWalletManager, RegTestLedger -from lbry.extras.daemon.Daemon import Daemon -from lbry.extras.daemon.Components import WalletComponent -from lbry.extras.daemon.Components import ( - DHT_COMPONENT, HASH_ANNOUNCER_COMPONENT, PEER_PROTOCOL_SERVER_COMPONENT, - UPNP_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT -) -from lbry.extras.daemon.ComponentManager import ComponentManager +import asyncio +from lbry.testcase import CommandTestCase +from binascii import unhexlify -class AccountSynchronization(AsyncioTestCase): +class WalletSynchronization(CommandTestCase): + SEED = "carbon smart garage balance margin twelve chest sword toast envelope bottom stomach absent" - async def asyncSetUp(self): - self.wallet_node = WalletNode(LbryWalletManager, RegTestLedger) - await self.wallet_node.start( - SPVNode(None), - "carbon smart garage balance margin twelve chest sword toast envelope bottom stomach absent", - False + async def test_sync(self): + daemon = self.daemon + daemon2 = await self.add_daemon( + seed="chest sword toast envelope bottom stomach absent " + "carbon smart garage balance margin twelve" ) - self.account = self.wallet_node.account + address = (await daemon2.wallet_manager.default_account.receiving.get_addresses(limit=1, only_usable=True))[0] + sendtxid = await self.blockchain.send_to_address(address, 1) + await self.confirm_tx(sendtxid, daemon2.ledger) - conf = Config() - conf.data_dir = self.wallet_node.data_path - conf.wallet_dir = self.wallet_node.data_path - conf.download_dir = self.wallet_node.data_path - conf.share_usage_data = False - conf.use_upnp = False - conf.reflect_streams = False - conf.blockchain_name = 'lbrycrd_regtest' - conf.lbryum_servers = [('localhost', 50001)] - conf.reflector_servers = [] - conf.known_dht_nodes = [] + # Preferences + self.assertFalse(daemon.jsonrpc_preference_get()) + self.assertFalse(daemon2.jsonrpc_preference_get()) - def wallet_maker(component_manager): - self.wallet_component = WalletComponent(component_manager) - self.wallet_component.wallet_manager = self.wallet_node.manager - self.wallet_component._running = True - return self.wallet_component + daemon.jsonrpc_preference_set("one", "1") + daemon.jsonrpc_preference_set("conflict", "1") + daemon.jsonrpc_preference_set("fruit", '["peach", "apricot"]') + await asyncio.sleep(1) + daemon2.jsonrpc_preference_set("two", "2") + daemon2.jsonrpc_preference_set("conflict", "2") - conf.components_to_skip = [ - DHT_COMPONENT, UPNP_COMPONENT, HASH_ANNOUNCER_COMPONENT, - PEER_PROTOCOL_SERVER_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT - ] - self.daemon = Daemon(conf, ComponentManager( - conf, skip_components=conf.components_to_skip, wallet=wallet_maker - )) - await self.daemon.initialize() + self.assertDictEqual(daemon.jsonrpc_preference_get(), { + "one": "1", "conflict": "1", "fruit": ["peach", "apricot"] + }) + self.assertDictEqual(daemon2.jsonrpc_preference_get(), {"two": "2", "conflict": "2"}) - async def asyncTearDown(self): - self.wallet_component._running = False - await self.daemon.stop(shutdown_runner=False) + self.assertEqual(len((await daemon.jsonrpc_account_list())['lbc_regtest']), 1) - @mock.patch('time.time', mock.Mock(return_value=12345)) - def test_sync(self): - starting_hash = '69afcd60a300f47933917d77ef011beeeb4decfafebbda91c144c84282c6814f' - self.account.modified_on = 123.456 - self.assertEqual(self.daemon.jsonrpc_sync_hash(), starting_hash) - self.assertEqual(self.daemon.jsonrpc_sync_apply('password')['hash'], starting_hash) - self.assertFalse(self.account.channel_keys) + data = await daemon2.jsonrpc_sync_apply('password') + await daemon.jsonrpc_sync_apply('password', data=data['data'], blocking=True) - hash_w_cert = '974721f42dab42657b5911b7caf4af98ce4d3879eea6ac23d50c1d79bc5020ef' - add_cert = ( - 'czo4MTkyOjE2OjE6qs3JRvS/bhX8p1JD68sqyA2Qhx3EVTqskhqEAwtfAUfUsQqeJ1rtMdRf40vkGKnpt4NT0b' - 'XEqb5O+lba4nkLF7vZENhc2zuOrjobCPVbiVHNwOfH56Ayrh1ts5LMcnl5+Mk1BUyGCwXcqEg2KiUkd3YZpiHQ' - 'T7WfcODcU6l7IRivb8iawCebZJx9waVyQoqEDKwZUY1i5HA0VLC+s5cV7it1AWbewiyWOQtZdEPzNY44oXLJex' - 'SirElQqDqNZyl3Hjy8YqacBbSYoejIRnmXpC9y25keP6hep3f9i1K2HDNwhwns1W1vhuzuO2Gy9+a0JlVm5mwc' - 'N2pqO4tCZr6tE3aym2FaSAunOi7QYVFMI6arb9Gvn9P+T+WRiFYfzwDFVR+j5ZPmUDXxHisy5OF163jH61wbBY' - 'pPienjlVtDOxoZmA8+AwWXKRdINsRcull9pu7EVCq5yQmrmxoPbLxNh5pRGrBB0JwCCOMIf+KPwS+7Z6dDbiwO' - '2NUpk8USJMTmXmFDCr2B0PJiG6Od2dD2oGN0F7aYZvUuKbqj8eDrJMe/zLbhq47jUjkJFCvtxUioo63ORk1pzH' - 'S0/X4/6/95PRSMaXm4DcZ9BdyxR2E/AKc8UN6AL5rrn6quXkC6R3ZhKgN3Si2S9y6EGFsL7dgzX331U08ZviLj' - 'NsrG0EKUnf+TGQ42MqnLQBOiO/ZoAwleOzNZnCYOQQ14Mm8y17xUpmdWRDiRKpAOJU22jKnxtqQ=' + self.assertEqual(len((await daemon.jsonrpc_account_list())['lbc_regtest']), 2) + self.assertDictEqual( + # "two" key added and "conflict" value changed to "2" + daemon.jsonrpc_preference_get(), + {"one": "1", "two": "2", "conflict": "2", "fruit": ["peach", "apricot"]} ) - self.daemon.jsonrpc_sync_apply('password', data=add_cert) - self.assertEqual(self.daemon.jsonrpc_sync_hash(), hash_w_cert) - self.assertEqual(self.account.channel_keys, {'abcdefg1234:0': '---PRIVATE KEY---'}) - # applying the same diff is idempotent - self.daemon.jsonrpc_sync_apply('password', data=add_cert) - self.assertEqual(self.daemon.jsonrpc_sync_hash(), hash_w_cert) - self.assertEqual(self.account.channel_keys, {'abcdefg1234:0': '---PRIVATE KEY---'}) + # Channel Certificate + channel = await daemon2.jsonrpc_channel_create('@foo', '0.1') + await daemon2.ledger.wait(channel) + await self.generate(1) + await daemon2.ledger.wait(channel) - @mock.patch('time.time', mock.Mock(return_value=12345)) - def test_account_preferences_syncing(self): - starting_hash = '69afcd60a300f47933917d77ef011beeeb4decfafebbda91c144c84282c6814f' - self.account.modified_on = 123.456 - self.assertEqual(self.daemon.jsonrpc_sync_hash(), starting_hash) - self.assertEqual(self.daemon.jsonrpc_sync_apply('password')['hash'], starting_hash) - self.assertFalse(self.daemon.jsonrpc_preference_get()) + # both daemons will have the channel but only one has the cert so far + self.assertEqual(len(await daemon.jsonrpc_channel_list()), 1) + self.assertEqual(len(daemon.wallet_manager.default_wallet.accounts[1].channel_keys), 0) + self.assertEqual(len(await daemon2.jsonrpc_channel_list()), 1) + self.assertEqual(len(daemon2.wallet_manager.default_account.channel_keys), 1) - hash_w_pref = '2fe43f0b2f8bbf1fbb55537f862d8bcb0823791019a7151c848bd5f5bd32d336' - add_pref = ( - 'czo4MTkyOjE2OjE6Jgn3nAGrfYP2usMA4KQ/73+YHAwMyiGdSWuxCmgZKlpwSpnfQv8R7R0tum/n2oTSBQxjdL' - 'OlTW+tv/G5L2GfQ5op3xaT89gN+F/JJnvf3cdWvYH7Nc+uTUMb7cKhJP7hQvFW5bb1Y3jX3EBBY00Jkqyj9RCR' - 'XPtbLVu71KbVRvCAR/oAnMEsgD+ITsC3WkXMwE3BS2LjJDQmeqbH4YXNdcjJN/JzQ6fxOmr3Uk1GqnpuhFsta8' - 'H14ViRilq1pLKOSZIN80rrm5cKq45nFO5kFeoqBCEaal4u2/OkX9nOnpQlO3E95wD8hkCmZ3i20aSte6nqwqXx' - 'ZKVRZqR2a0TjwVWB8kPXPA2ewKvPILaj190bXPl8EVu+TAnTCQwMgytinYjtcKNZmMz3ENJyI2mCANwpWlX7xl' - 'y/J+qLi5b9N+agghTxggs5rVJ/hkaue7GS542dXDrwMrw9nwGqNw3dS/lcU+1wRUQ0fnHwb/85XbbwyO2aDj2i' - 'DFNkdyLyUIiIUvB1JfWAnWqX3vQcL1REK1ePgUei7dCHJ3WyWdsRx3cVXzlK8yOPkf0N6d3AKrZQWVebwDC7Nd' - 'eL4sDW8AkaXuBIrbuZw6XUHd6WI0NvU/q10j2qMm0YoXSu+dExou1/1THwx5g86MxcX5nwodKUEVCOTzKMyrLz' - 'CRsitH/+dAXhZNRp/FbnDCGBMyD3MOYCjZvAFbCZUasoRwqponxILw==' + data = await daemon2.jsonrpc_sync_apply('password') + await daemon.jsonrpc_sync_apply('password', data=data['data'], blocking=True) + + # both daemons have the cert after sync'ing + self.assertEqual( + daemon2.wallet_manager.default_account.channel_keys, + daemon.wallet_manager.default_wallet.accounts[1].channel_keys ) - self.daemon.jsonrpc_sync_apply('password', data=add_pref) - self.assertEqual(self.daemon.jsonrpc_sync_hash(), hash_w_pref) - self.assertEqual(self.daemon.jsonrpc_preference_get(), {"fruit": ["apple", "orange"]}) - - self.daemon.jsonrpc_preference_set("fruit", ["peach", "apricot"]) - self.assertEqual(self.daemon.jsonrpc_preference_get(), {"fruit": ["peach", "apricot"]}) - self.assertNotEqual(self.daemon.jsonrpc_sync_hash(), hash_w_pref) diff --git a/torba/tests/client_tests/unit/test_account.py b/torba/tests/client_tests/unit/test_account.py index 74a455d07..a9785ac2d 100644 --- a/torba/tests/client_tests/unit/test_account.py +++ b/torba/tests/client_tests/unit/test_account.py @@ -178,7 +178,7 @@ class TestHierarchicalDeterministicAccount(AsyncioTestCase): account_data['ledger'] = 'btc_mainnet' self.assertDictEqual(account_data, account.to_dict()) - def test_apply_diff(self): + def test_merge_diff(self): account_data = { 'name': 'My Account', 'modified_on': 123.456, @@ -213,13 +213,13 @@ class TestHierarchicalDeterministicAccount(AsyncioTestCase): account_data['address_generator']['receiving']['gap'] = 8 account_data['address_generator']['receiving']['maximum_uses_per_address'] = 9 - account.apply(account_data) + account.merge(account_data) # no change because modified_on is not newer self.assertEqual(account.name, 'My Account') account_data['modified_on'] = 200.00 - account.apply(account_data) + account.merge(account_data) self.assertEqual(account.name, 'Changed Name') self.assertEqual(account.change.gap, 6) self.assertEqual(account.change.maximum_uses_per_address, 7) diff --git a/torba/tests/client_tests/unit/test_wallet.py b/torba/tests/client_tests/unit/test_wallet.py index cdd8392ce..a9424e2d4 100644 --- a/torba/tests/client_tests/unit/test_wallet.py +++ b/torba/tests/client_tests/unit/test_wallet.py @@ -1,12 +1,13 @@ import tempfile from binascii import hexlify +from unittest import TestCase, mock from torba.testcase import AsyncioTestCase from torba.coin.bitcoinsegwit import MainNetLedger as BTCLedger from torba.coin.bitcoincash import MainNetLedger as BCHLedger from torba.client.basemanager import BaseWalletManager -from torba.client.wallet import Wallet, WalletStorage +from torba.client.wallet import Wallet, WalletStorage, TimestampedPreferences class TestWalletCreation(AsyncioTestCase): @@ -32,6 +33,7 @@ class TestWalletCreation(AsyncioTestCase): wallet_dict = { 'version': 1, 'name': 'Main Wallet', + 'preferences': {}, 'accounts': [ { 'name': 'An Account', @@ -60,7 +62,7 @@ class TestWalletCreation(AsyncioTestCase): wallet = Wallet.from_storage(storage, self.manager) self.assertEqual(wallet.name, 'Main Wallet') self.assertEqual( - hexlify(wallet.hash), b'9f462b8dd802eb8c913e54f09a09827ebc14abbc13f33baa90d8aec5ae920fc7' + hexlify(wallet.hash), b'1bd61fbe18875cb7828c466022af576104ed861c8a1fdb1dadf5e39417a68483' ) self.assertEqual(len(wallet.accounts), 1) account = wallet.default_account @@ -91,3 +93,56 @@ class TestWalletCreation(AsyncioTestCase): wallet = Wallet.from_storage(wallet_storage, manager) self.assertEqual(account.public_key.address, wallet.default_account.public_key.address) + + def test_merge(self): + wallet1 = Wallet() + wallet1.preferences['one'] = 1 + wallet1.preferences['conflict'] = 1 + wallet1.generate_account(self.btc_ledger) + wallet2 = Wallet() + wallet2.preferences['two'] = 2 + wallet2.preferences['conflict'] = 2 # will be more recent + wallet2.generate_account(self.btc_ledger) + + self.assertEqual(len(wallet1.accounts), 1) + self.assertEqual(wallet1.preferences, {'one': 1, 'conflict': 1}) + + added = wallet1.merge(self.manager, 'password', wallet2.pack('password')) + self.assertEqual(added[0].id, wallet2.default_account.id) + self.assertEqual(len(wallet1.accounts), 2) + self.assertEqual(wallet1.accounts[1].id, wallet2.default_account.id) + self.assertEqual(wallet1.preferences, {'one': 1, 'two': 2, 'conflict': 2}) + + +class TestTimestampedPreferences(TestCase): + + def test_hash(self): + p = TimestampedPreferences() + self.assertEqual( + hexlify(p.hash), b'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' + ) + with mock.patch('time.time', mock.Mock(return_value=12345)): + p['one'] = 1 + self.assertEqual( + hexlify(p.hash), b'c9e82bf4cb099dd0125f78fa381b21a8131af601917eb531e1f5f980f8f3da66' + ) + + def test_merge(self): + p1 = TimestampedPreferences() + p2 = TimestampedPreferences() + with mock.patch('time.time', mock.Mock(return_value=10)): + p1['one'] = 1 + p1['conflict'] = 1 + with mock.patch('time.time', mock.Mock(return_value=20)): + p2['two'] = 2 + p2['conflict'] = 2 + + # conflict in p2 overrides conflict in p1 + p1.merge(p2.data) + self.assertEqual(p1, {'one': 1, 'two': 2, 'conflict': 2}) + + # have a newer conflict in p1 so it is not overridden this time + with mock.patch('time.time', mock.Mock(return_value=21)): + p1['conflict'] = 1 + p1.merge(p2.data) + self.assertEqual(p1, {'one': 1, 'two': 2, 'conflict': 1}) diff --git a/torba/torba/client/baseaccount.py b/torba/torba/client/baseaccount.py index 4c7644fe6..21431c662 100644 --- a/torba/torba/client/baseaccount.py +++ b/torba/torba/client/baseaccount.py @@ -42,7 +42,7 @@ class AddressManager: d['change'] = change_dict return d - def apply(self, d: dict): + def merge(self, d: dict): pass def to_dict_instance(self) -> Optional[dict]: @@ -101,7 +101,7 @@ class HierarchicalDeterministic(AddressManager): cls(account, 1, **d.get('change', {'gap': 6, 'maximum_uses_per_address': 1})) ) - def apply(self, d: dict): + def merge(self, d: dict): self.gap = d.get('gap', self.gap) self.maximum_uses_per_address = d.get('maximum_uses_per_address', self.maximum_uses_per_address) @@ -310,7 +310,7 @@ class BaseAccount: 'modified_on': self.modified_on } - def apply(self, d: dict): + def merge(self, d: dict): if d.get('modified_on', 0) > self.modified_on: self.name = d['name'] self.modified_on = d.get('modified_on', time.time()) @@ -318,7 +318,7 @@ class BaseAccount: for chain_name in ('change', 'receiving'): if chain_name in d['address_generator']: chain_object = getattr(self, chain_name) - chain_object.apply(d['address_generator'][chain_name]) + chain_object.merge(d['address_generator'][chain_name]) @property def hash(self) -> bytes: diff --git a/torba/torba/client/wallet.py b/torba/torba/client/wallet.py index 83a2416c2..d64e2cea7 100644 --- a/torba/torba/client/wallet.py +++ b/torba/torba/client/wallet.py @@ -1,8 +1,10 @@ import os +import time import stat import json import zlib import typing +from collections import UserDict from typing import List, Sequence, MutableSequence, Optional from hashlib import sha256 from operator import attrgetter @@ -12,6 +14,36 @@ if typing.TYPE_CHECKING: from torba.client import basemanager, baseaccount, baseledger +class TimestampedPreferences(UserDict): + + def __getitem__(self, key): + return self.data[key]['value'] + + def __setitem__(self, key, value): + self.data[key] = { + 'value': value, + 'ts': time.time() + } + + def __repr__(self): + return repr(self.to_dict_without_ts()) + + def to_dict_without_ts(self): + return { + key: value['value'] for key, value in self.data.items() + } + + @property + def hash(self): + return sha256(json.dumps(self.data).encode()).digest() + + def merge(self, other: dict): + for key, value in other.items(): + if key in self.data and value['ts'] < self.data[key]['ts']: + continue + self.data[key] = value + + class Wallet: """ The primary role of Wallet is to encapsulate a collection of accounts (seed/private keys) and the spending rules / settings @@ -19,11 +51,14 @@ class Wallet: by physical files on the filesystem. """ + preferences: TimestampedPreferences + def __init__(self, name: str = 'Wallet', accounts: MutableSequence['baseaccount.BaseAccount'] = None, - storage: 'WalletStorage' = None) -> None: + storage: 'WalletStorage' = None, preferences: dict = None) -> None: self.name = name self.accounts = accounts or [] self.storage = storage or WalletStorage() + self.preferences = TimestampedPreferences(preferences or {}) @property def id(self): @@ -75,6 +110,7 @@ class Wallet: json_dict = storage.read() wallet = cls( name=json_dict.get('name', 'Wallet'), + preferences=json_dict.get('preferences', {}), storage=storage ) account_dicts: Sequence[dict] = json_dict.get('accounts', []) @@ -87,6 +123,7 @@ class Wallet: return { 'version': WalletStorage.LATEST_VERSION, 'name': self.name, + 'preferences': self.preferences.data, 'accounts': [a.to_dict() for a in self.accounts] } @@ -96,6 +133,7 @@ class Wallet: @property def hash(self) -> bytes: h = sha256() + h.update(self.preferences.hash) for account in sorted(self.accounts, key=attrgetter('id')): h.update(account.hash) return h.digest() @@ -111,6 +149,27 @@ class Wallet: decompressed = zlib.decompress(decrypted) return json.loads(decompressed) + def merge(self, manager: 'basemanager.BaseWalletManager', + password: str, data: str) -> List['baseaccount.BaseAccount']: + added_accounts = [] + decrypted_data = self.unpack(password, data) + self.preferences.merge(decrypted_data.get('preferences', {})) + for account_dict in decrypted_data['accounts']: + ledger = manager.get_or_create_ledger(account_dict['ledger']) + _, _, pubkey = ledger.account_class.keys_from_dict(ledger, account_dict) + account_id = pubkey.address + local_match = None + for local_account in self.accounts: + if account_id == local_account.id: + local_match = local_account + break + if local_match is not None: + local_match.merge(account_dict) + else: + new_account = ledger.account_class.from_dict(ledger, self, account_dict) + added_accounts.append(new_account) + return added_accounts + class WalletStorage: @@ -121,6 +180,7 @@ class WalletStorage: self._default = default or { 'version': self.LATEST_VERSION, 'name': 'My Wallet', + 'preferences': {}, 'accounts': [] } diff --git a/torba/torba/orchstr8/node.py b/torba/torba/orchstr8/node.py index 42cf49777..dec772102 100644 --- a/torba/torba/orchstr8/node.py +++ b/torba/torba/orchstr8/node.py @@ -68,7 +68,7 @@ def set_logging(ledger_module, level, handler=None): class Conductor: def __init__(self, ledger_module=None, manager_module=None, verbosity=logging.WARNING, - enable_segwit=False): + enable_segwit=False, seed=None): self.ledger_module = ledger_module or get_ledger_from_environment() self.manager_module = manager_module or get_manager_from_environment() self.spv_module = get_spvserver_from_ledger(self.ledger_module) @@ -76,7 +76,9 @@ class Conductor: self.blockchain_node = get_blockchain_node_from_ledger(self.ledger_module) self.blockchain_node.segwit_enabled = enable_segwit self.spv_node = SPVNode(self.spv_module) - self.wallet_node = WalletNode(self.manager_module, self.ledger_module.RegTestLedger) + self.wallet_node = WalletNode( + self.manager_module, self.ledger_module.RegTestLedger, default_seed=seed + ) set_logging(self.ledger_module, verbosity) @@ -138,7 +140,7 @@ class Conductor: class WalletNode: def __init__(self, manager_class: Type[BaseWalletManager], ledger_class: Type[BaseLedger], - verbose: bool = False, port: int = 5280) -> None: + verbose: bool = False, port: int = 5280, default_seed: str = None) -> None: self.manager_class = manager_class self.ledger_class = ledger_class self.verbose = verbose @@ -148,6 +150,7 @@ class WalletNode: self.account: Optional[BaseAccount] = None self.data_path: Optional[str] = None self.port = port + self.default_seed = default_seed async def start(self, spv_node: 'SPVNode', seed=None, connect=True): self.data_path = tempfile.mkdtemp() @@ -168,12 +171,12 @@ class WalletNode: }) self.ledger = self.manager.ledgers[self.ledger_class] self.wallet = self.manager.default_wallet - if seed is None and self.wallet is not None: - self.wallet.generate_account(self.ledger) - elif self.wallet is not None: + if seed or self.default_seed: self.ledger.account_class.from_dict( - self.ledger, self.wallet, {'seed': seed} + self.ledger, self.wallet, {'seed': seed or self.default_seed} ) + elif self.wallet is not None: + self.wallet.generate_account(self.ledger) else: raise ValueError('Wallet is required.') self.account = self.wallet.default_account diff --git a/torba/torba/testcase.py b/torba/torba/testcase.py index b7c9ccbba..89bb14b5c 100644 --- a/torba/torba/testcase.py +++ b/torba/torba/testcase.py @@ -183,6 +183,7 @@ class AdvanceTimeTestCase(AsyncioTestCase): class IntegrationTestCase(AsyncioTestCase): + SEED = None LEDGER = None MANAGER = None ENABLE_SEGWIT = False @@ -201,7 +202,7 @@ class IntegrationTestCase(AsyncioTestCase): async def asyncSetUp(self): self.conductor = Conductor( ledger_module=self.LEDGER, manager_module=self.MANAGER, verbosity=self.VERBOSITY, - enable_segwit=self.ENABLE_SEGWIT + enable_segwit=self.ENABLE_SEGWIT, seed=self.SEED ) await self.conductor.start_blockchain() self.addCleanup(self.conductor.stop_blockchain)