timestamped top-level wallet preferences

This commit is contained in:
Lex Berezhny 2019-10-12 19:33:16 -04:00
parent c51cc02a87
commit 37ae302fc6
10 changed files with 211 additions and 158 deletions

View file

@ -999,7 +999,7 @@ class Daemon(metaclass=JSONRPCServerType):
Preferences management. 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. Get preference value for key or all values if not key is passed in.
@ -1008,21 +1008,19 @@ class Daemon(metaclass=JSONRPCServerType):
Options: Options:
--key=<key> : (str) key associated with value --key=<key> : (str) key associated with value
--account_id=<account_id> : (str) id of the account containing value
--wallet_id=<wallet_id> : (str) restrict operation to specific wallet --wallet_id=<wallet_id> : (str) restrict operation to specific wallet
Returns: Returns:
(dict) Dictionary of preference(s) (dict) Dictionary of preference(s)
""" """
wallet = self.wallet_manager.get_wallet_or_default(wallet_id) wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
account = wallet.get_account_or_default(account_id)
if key: if key:
if key in account.preferences: if key in wallet.preferences:
return {key: account.preferences[key]} return {key: wallet.preferences[key]}
return 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 Set preferences
@ -1032,18 +1030,15 @@ class Daemon(metaclass=JSONRPCServerType):
Options: Options:
--key=<key> : (str) key associated with value --key=<key> : (str) key associated with value
--value=<key> : (str) key associated with value --value=<key> : (str) key associated with value
--account_id=<account_id> : (str) id of the account containing value
--wallet_id=<wallet_id> : (str) restrict operation to specific wallet --wallet_id=<wallet_id> : (str) restrict operation to specific wallet
Returns: Returns:
(dict) Dictionary with key/value of new preference (dict) Dictionary with key/value of new preference
""" """
wallet = self.wallet_manager.get_wallet_or_default(wallet_id) 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 ('[', '{'): if value and isinstance(value, str) and value[0] in ('[', '{'):
value = json.loads(value) value = json.loads(value)
account.preferences[key] = value wallet.preferences[key] = value
account.modified_on = time.time()
wallet.save() wallet.save()
return {key: value} return {key: value}
@ -1342,7 +1337,7 @@ class Daemon(metaclass=JSONRPCServerType):
account.name = new_name account.name = new_name
change_made = True change_made = True
if default: if default and wallet.default_account != account:
wallet.accounts.remove(account) wallet.accounts.remove(account)
wallet.accounts.insert(0, account) wallet.accounts.insert(0, account)
change_made = True change_made = True
@ -1578,14 +1573,14 @@ class Daemon(metaclass=JSONRPCServerType):
return hexlify(wallet.hash).decode() return hexlify(wallet.hash).decode()
@requires("wallet") @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 Apply incoming synchronization data, if provided, and then produce a sync hash and
an encrypted wallet. an encrypted wallet.
Usage: Usage:
sync_apply <password> [--data=<data>] [--encrypt-password=<encrypt_password>] sync_apply <password> [--data=<data>] [--encrypt-password=<encrypt_password>]
[--wallet_id=<wallet_id>] [--wallet_id=<wallet_id>] [--blocking]
Options: Options:
--password=<password> : (str) password to decrypt incoming and encrypt outgoing data --password=<password> : (str) password to decrypt incoming and encrypt outgoing data
@ -1593,6 +1588,7 @@ class Daemon(metaclass=JSONRPCServerType):
--encrypt-password=<encrypt_password> : (str) password to encrypt outgoing data if different --encrypt-password=<encrypt_password> : (str) password to encrypt outgoing data if different
from the decrypt password, used during password changes from the decrypt password, used during password changes
--wallet_id=<wallet_id> : (str) wallet being sync'ed --wallet_id=<wallet_id> : (str) wallet being sync'ed
--blocking : (bool) wait until any new accounts have sync'ed
Returns: Returns:
(map) sync hash and data (map) sync hash and data
@ -1600,23 +1596,16 @@ class Daemon(metaclass=JSONRPCServerType):
""" """
wallet = self.wallet_manager.get_wallet_or_default(wallet_id) wallet = self.wallet_manager.get_wallet_or_default(wallet_id)
if data is not None: if data is not None:
decrypted_data = Wallet.unpack(password, data) added_accounts = wallet.merge(self.wallet_manager, password, data)
for account_data in decrypted_data['accounts']: if added_accounts and self.ledger.network.is_connected:
_, _, pubkey = LBCAccount.keys_from_dict(self.ledger, account_data) if blocking:
account_id = pubkey.address await asyncio.wait([
local_match = None a.ledger.subscribe_account(a) for a in added_accounts
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)
else: else:
new_account = LBCAccount.from_dict(self.ledger, wallet, account_data) for new_account in added_accounts:
if self.ledger.network.is_connected:
asyncio.create_task(self.ledger.subscribe_account(new_account)) asyncio.create_task(self.ledger.subscribe_account(new_account))
wallet.save() wallet.save()
encrypted = wallet.pack(encrypt_password or password) encrypted = wallet.pack(encrypt_password or password)
return { return {
'hash': self.jsonrpc_sync_hash(wallet_id), 'hash': self.jsonrpc_sync_hash(wallet_id),

View file

@ -151,11 +151,11 @@ class CommandTestCase(IntegrationTestCase):
wallet_node.manager.old_db = daemon.storage wallet_node.manager.old_db = daemon.storage
return daemon 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. """ """ 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.generate(1)
await self.on_transaction_id(txid) await self.on_transaction_id(txid, ledger)
return txid return txid
async def on_transaction_dict(self, tx): async def on_transaction_dict(self, tx):

View file

@ -28,7 +28,6 @@ class Account(BaseAccount):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.channel_keys = {} self.channel_keys = {}
self.preferences = {}
@property @property
def hash(self) -> bytes: def hash(self) -> bytes:
@ -37,10 +36,9 @@ class Account(BaseAccount):
h.update(cert.encode()) h.update(cert.encode())
return h.digest() return h.digest()
def apply(self, d: dict): def merge(self, d: dict):
super().apply(d) super().merge(d)
self.channel_keys.update(d.get('certificates', {})) self.channel_keys.update(d.get('certificates', {}))
self.preferences.update(d.get('preferences', {}))
def add_channel_private_key(self, private_key): def add_channel_private_key(self, private_key):
public_key_bytes = private_key.get_verifying_key().to_der() 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': def from_dict(cls, ledger, wallet, d: dict) -> 'Account':
account = super().from_dict(ledger, wallet, d) account = super().from_dict(ledger, wallet, d)
account.channel_keys = d.get('certificates', {}) account.channel_keys = d.get('certificates', {})
account.preferences = d.get('preferences', {})
return account 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() d = super().to_dict()
if include_channel_keys: if include_channel_keys:
d['certificates'] = self.channel_keys d['certificates'] = self.channel_keys
if include_preferences and self.preferences:
d['preferences'] = self.preferences
return d return d
async def get_details(self, **kwargs): async def get_details(self, **kwargs):
details = await super().get_details(**kwargs) details = await super().get_details(**kwargs)
details['certificates'] = len(self.channel_keys) details['certificates'] = len(self.channel_keys)
if self.preferences:
details['preferences'] = self.preferences
return details return details
def get_transaction_history(self, **constraints): def get_transaction_history(self, **constraints):

View file

@ -1,114 +1,66 @@
from unittest import mock import asyncio
from lbry.testcase import CommandTestCase
from torba.orchstr8.node import WalletNode, SPVNode from binascii import unhexlify
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
class AccountSynchronization(AsyncioTestCase): class WalletSynchronization(CommandTestCase):
SEED = "carbon smart garage balance margin twelve chest sword toast envelope bottom stomach absent"
async def asyncSetUp(self): async def test_sync(self):
self.wallet_node = WalletNode(LbryWalletManager, RegTestLedger) daemon = self.daemon
await self.wallet_node.start( daemon2 = await self.add_daemon(
SPVNode(None), seed="chest sword toast envelope bottom stomach absent "
"carbon smart garage balance margin twelve chest sword toast envelope bottom stomach absent", "carbon smart garage balance margin twelve"
False
) )
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() # Preferences
conf.data_dir = self.wallet_node.data_path self.assertFalse(daemon.jsonrpc_preference_get())
conf.wallet_dir = self.wallet_node.data_path self.assertFalse(daemon2.jsonrpc_preference_get())
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 = []
def wallet_maker(component_manager): daemon.jsonrpc_preference_set("one", "1")
self.wallet_component = WalletComponent(component_manager) daemon.jsonrpc_preference_set("conflict", "1")
self.wallet_component.wallet_manager = self.wallet_node.manager daemon.jsonrpc_preference_set("fruit", '["peach", "apricot"]')
self.wallet_component._running = True await asyncio.sleep(1)
return self.wallet_component daemon2.jsonrpc_preference_set("two", "2")
daemon2.jsonrpc_preference_set("conflict", "2")
conf.components_to_skip = [ self.assertDictEqual(daemon.jsonrpc_preference_get(), {
DHT_COMPONENT, UPNP_COMPONENT, HASH_ANNOUNCER_COMPONENT, "one": "1", "conflict": "1", "fruit": ["peach", "apricot"]
PEER_PROTOCOL_SERVER_COMPONENT, EXCHANGE_RATE_MANAGER_COMPONENT })
] self.assertDictEqual(daemon2.jsonrpc_preference_get(), {"two": "2", "conflict": "2"})
self.daemon = Daemon(conf, ComponentManager(
conf, skip_components=conf.components_to_skip, wallet=wallet_maker
))
await self.daemon.initialize()
async def asyncTearDown(self): self.assertEqual(len((await daemon.jsonrpc_account_list())['lbc_regtest']), 1)
self.wallet_component._running = False
await self.daemon.stop(shutdown_runner=False)
@mock.patch('time.time', mock.Mock(return_value=12345)) data = await daemon2.jsonrpc_sync_apply('password')
def test_sync(self): await daemon.jsonrpc_sync_apply('password', data=data['data'], blocking=True)
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)
hash_w_cert = '974721f42dab42657b5911b7caf4af98ce4d3879eea6ac23d50c1d79bc5020ef' self.assertEqual(len((await daemon.jsonrpc_account_list())['lbc_regtest']), 2)
add_cert = ( self.assertDictEqual(
'czo4MTkyOjE2OjE6qs3JRvS/bhX8p1JD68sqyA2Qhx3EVTqskhqEAwtfAUfUsQqeJ1rtMdRf40vkGKnpt4NT0b' # "two" key added and "conflict" value changed to "2"
'XEqb5O+lba4nkLF7vZENhc2zuOrjobCPVbiVHNwOfH56Ayrh1ts5LMcnl5+Mk1BUyGCwXcqEg2KiUkd3YZpiHQ' daemon.jsonrpc_preference_get(),
'T7WfcODcU6l7IRivb8iawCebZJx9waVyQoqEDKwZUY1i5HA0VLC+s5cV7it1AWbewiyWOQtZdEPzNY44oXLJex' {"one": "1", "two": "2", "conflict": "2", "fruit": ["peach", "apricot"]}
'SirElQqDqNZyl3Hjy8YqacBbSYoejIRnmXpC9y25keP6hep3f9i1K2HDNwhwns1W1vhuzuO2Gy9+a0JlVm5mwc'
'N2pqO4tCZr6tE3aym2FaSAunOi7QYVFMI6arb9Gvn9P+T+WRiFYfzwDFVR+j5ZPmUDXxHisy5OF163jH61wbBY'
'pPienjlVtDOxoZmA8+AwWXKRdINsRcull9pu7EVCq5yQmrmxoPbLxNh5pRGrBB0JwCCOMIf+KPwS+7Z6dDbiwO'
'2NUpk8USJMTmXmFDCr2B0PJiG6Od2dD2oGN0F7aYZvUuKbqj8eDrJMe/zLbhq47jUjkJFCvtxUioo63ORk1pzH'
'S0/X4/6/95PRSMaXm4DcZ9BdyxR2E/AKc8UN6AL5rrn6quXkC6R3ZhKgN3Si2S9y6EGFsL7dgzX331U08ZviLj'
'NsrG0EKUnf+TGQ42MqnLQBOiO/ZoAwleOzNZnCYOQQ14Mm8y17xUpmdWRDiRKpAOJU22jKnxtqQ='
) )
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 # Channel Certificate
self.daemon.jsonrpc_sync_apply('password', data=add_cert) channel = await daemon2.jsonrpc_channel_create('@foo', '0.1')
self.assertEqual(self.daemon.jsonrpc_sync_hash(), hash_w_cert) await daemon2.ledger.wait(channel)
self.assertEqual(self.account.channel_keys, {'abcdefg1234:0': '---PRIVATE KEY---'}) await self.generate(1)
await daemon2.ledger.wait(channel)
@mock.patch('time.time', mock.Mock(return_value=12345)) # both daemons will have the channel but only one has the cert so far
def test_account_preferences_syncing(self): self.assertEqual(len(await daemon.jsonrpc_channel_list()), 1)
starting_hash = '69afcd60a300f47933917d77ef011beeeb4decfafebbda91c144c84282c6814f' self.assertEqual(len(daemon.wallet_manager.default_wallet.accounts[1].channel_keys), 0)
self.account.modified_on = 123.456 self.assertEqual(len(await daemon2.jsonrpc_channel_list()), 1)
self.assertEqual(self.daemon.jsonrpc_sync_hash(), starting_hash) self.assertEqual(len(daemon2.wallet_manager.default_account.channel_keys), 1)
self.assertEqual(self.daemon.jsonrpc_sync_apply('password')['hash'], starting_hash)
self.assertFalse(self.daemon.jsonrpc_preference_get())
hash_w_pref = '2fe43f0b2f8bbf1fbb55537f862d8bcb0823791019a7151c848bd5f5bd32d336' data = await daemon2.jsonrpc_sync_apply('password')
add_pref = ( await daemon.jsonrpc_sync_apply('password', data=data['data'], blocking=True)
'czo4MTkyOjE2OjE6Jgn3nAGrfYP2usMA4KQ/73+YHAwMyiGdSWuxCmgZKlpwSpnfQv8R7R0tum/n2oTSBQxjdL'
'OlTW+tv/G5L2GfQ5op3xaT89gN+F/JJnvf3cdWvYH7Nc+uTUMb7cKhJP7hQvFW5bb1Y3jX3EBBY00Jkqyj9RCR' # both daemons have the cert after sync'ing
'XPtbLVu71KbVRvCAR/oAnMEsgD+ITsC3WkXMwE3BS2LjJDQmeqbH4YXNdcjJN/JzQ6fxOmr3Uk1GqnpuhFsta8' self.assertEqual(
'H14ViRilq1pLKOSZIN80rrm5cKq45nFO5kFeoqBCEaal4u2/OkX9nOnpQlO3E95wD8hkCmZ3i20aSte6nqwqXx' daemon2.wallet_manager.default_account.channel_keys,
'ZKVRZqR2a0TjwVWB8kPXPA2ewKvPILaj190bXPl8EVu+TAnTCQwMgytinYjtcKNZmMz3ENJyI2mCANwpWlX7xl' daemon.wallet_manager.default_wallet.accounts[1].channel_keys
'y/J+qLi5b9N+agghTxggs5rVJ/hkaue7GS542dXDrwMrw9nwGqNw3dS/lcU+1wRUQ0fnHwb/85XbbwyO2aDj2i'
'DFNkdyLyUIiIUvB1JfWAnWqX3vQcL1REK1ePgUei7dCHJ3WyWdsRx3cVXzlK8yOPkf0N6d3AKrZQWVebwDC7Nd'
'eL4sDW8AkaXuBIrbuZw6XUHd6WI0NvU/q10j2qMm0YoXSu+dExou1/1THwx5g86MxcX5nwodKUEVCOTzKMyrLz'
'CRsitH/+dAXhZNRp/FbnDCGBMyD3MOYCjZvAFbCZUasoRwqponxILw=='
) )
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)

View file

@ -178,7 +178,7 @@ class TestHierarchicalDeterministicAccount(AsyncioTestCase):
account_data['ledger'] = 'btc_mainnet' account_data['ledger'] = 'btc_mainnet'
self.assertDictEqual(account_data, account.to_dict()) self.assertDictEqual(account_data, account.to_dict())
def test_apply_diff(self): def test_merge_diff(self):
account_data = { account_data = {
'name': 'My Account', 'name': 'My Account',
'modified_on': 123.456, 'modified_on': 123.456,
@ -213,13 +213,13 @@ class TestHierarchicalDeterministicAccount(AsyncioTestCase):
account_data['address_generator']['receiving']['gap'] = 8 account_data['address_generator']['receiving']['gap'] = 8
account_data['address_generator']['receiving']['maximum_uses_per_address'] = 9 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 # no change because modified_on is not newer
self.assertEqual(account.name, 'My Account') self.assertEqual(account.name, 'My Account')
account_data['modified_on'] = 200.00 account_data['modified_on'] = 200.00
account.apply(account_data) account.merge(account_data)
self.assertEqual(account.name, 'Changed Name') self.assertEqual(account.name, 'Changed Name')
self.assertEqual(account.change.gap, 6) self.assertEqual(account.change.gap, 6)
self.assertEqual(account.change.maximum_uses_per_address, 7) self.assertEqual(account.change.maximum_uses_per_address, 7)

View file

@ -1,12 +1,13 @@
import tempfile import tempfile
from binascii import hexlify from binascii import hexlify
from unittest import TestCase, mock
from torba.testcase import AsyncioTestCase from torba.testcase import AsyncioTestCase
from torba.coin.bitcoinsegwit import MainNetLedger as BTCLedger from torba.coin.bitcoinsegwit import MainNetLedger as BTCLedger
from torba.coin.bitcoincash import MainNetLedger as BCHLedger from torba.coin.bitcoincash import MainNetLedger as BCHLedger
from torba.client.basemanager import BaseWalletManager from torba.client.basemanager import BaseWalletManager
from torba.client.wallet import Wallet, WalletStorage from torba.client.wallet import Wallet, WalletStorage, TimestampedPreferences
class TestWalletCreation(AsyncioTestCase): class TestWalletCreation(AsyncioTestCase):
@ -32,6 +33,7 @@ class TestWalletCreation(AsyncioTestCase):
wallet_dict = { wallet_dict = {
'version': 1, 'version': 1,
'name': 'Main Wallet', 'name': 'Main Wallet',
'preferences': {},
'accounts': [ 'accounts': [
{ {
'name': 'An Account', 'name': 'An Account',
@ -60,7 +62,7 @@ class TestWalletCreation(AsyncioTestCase):
wallet = Wallet.from_storage(storage, self.manager) wallet = Wallet.from_storage(storage, self.manager)
self.assertEqual(wallet.name, 'Main Wallet') self.assertEqual(wallet.name, 'Main Wallet')
self.assertEqual( self.assertEqual(
hexlify(wallet.hash), b'9f462b8dd802eb8c913e54f09a09827ebc14abbc13f33baa90d8aec5ae920fc7' hexlify(wallet.hash), b'1bd61fbe18875cb7828c466022af576104ed861c8a1fdb1dadf5e39417a68483'
) )
self.assertEqual(len(wallet.accounts), 1) self.assertEqual(len(wallet.accounts), 1)
account = wallet.default_account account = wallet.default_account
@ -91,3 +93,56 @@ class TestWalletCreation(AsyncioTestCase):
wallet = Wallet.from_storage(wallet_storage, manager) wallet = Wallet.from_storage(wallet_storage, manager)
self.assertEqual(account.public_key.address, wallet.default_account.public_key.address) 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})

View file

@ -42,7 +42,7 @@ class AddressManager:
d['change'] = change_dict d['change'] = change_dict
return d return d
def apply(self, d: dict): def merge(self, d: dict):
pass pass
def to_dict_instance(self) -> Optional[dict]: 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})) 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.gap = d.get('gap', self.gap)
self.maximum_uses_per_address = d.get('maximum_uses_per_address', self.maximum_uses_per_address) 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 'modified_on': self.modified_on
} }
def apply(self, d: dict): def merge(self, d: dict):
if d.get('modified_on', 0) > self.modified_on: if d.get('modified_on', 0) > self.modified_on:
self.name = d['name'] self.name = d['name']
self.modified_on = d.get('modified_on', time.time()) self.modified_on = d.get('modified_on', time.time())
@ -318,7 +318,7 @@ class BaseAccount:
for chain_name in ('change', 'receiving'): for chain_name in ('change', 'receiving'):
if chain_name in d['address_generator']: if chain_name in d['address_generator']:
chain_object = getattr(self, chain_name) chain_object = getattr(self, chain_name)
chain_object.apply(d['address_generator'][chain_name]) chain_object.merge(d['address_generator'][chain_name])
@property @property
def hash(self) -> bytes: def hash(self) -> bytes:

View file

@ -1,8 +1,10 @@
import os import os
import time
import stat import stat
import json import json
import zlib import zlib
import typing import typing
from collections import UserDict
from typing import List, Sequence, MutableSequence, Optional from typing import List, Sequence, MutableSequence, Optional
from hashlib import sha256 from hashlib import sha256
from operator import attrgetter from operator import attrgetter
@ -12,6 +14,36 @@ if typing.TYPE_CHECKING:
from torba.client import basemanager, baseaccount, baseledger 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: class Wallet:
""" The primary role of Wallet is to encapsulate a collection """ The primary role of Wallet is to encapsulate a collection
of accounts (seed/private keys) and the spending rules / settings of accounts (seed/private keys) and the spending rules / settings
@ -19,11 +51,14 @@ class Wallet:
by physical files on the filesystem. by physical files on the filesystem.
""" """
preferences: TimestampedPreferences
def __init__(self, name: str = 'Wallet', accounts: MutableSequence['baseaccount.BaseAccount'] = None, 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.name = name
self.accounts = accounts or [] self.accounts = accounts or []
self.storage = storage or WalletStorage() self.storage = storage or WalletStorage()
self.preferences = TimestampedPreferences(preferences or {})
@property @property
def id(self): def id(self):
@ -75,6 +110,7 @@ class Wallet:
json_dict = storage.read() json_dict = storage.read()
wallet = cls( wallet = cls(
name=json_dict.get('name', 'Wallet'), name=json_dict.get('name', 'Wallet'),
preferences=json_dict.get('preferences', {}),
storage=storage storage=storage
) )
account_dicts: Sequence[dict] = json_dict.get('accounts', []) account_dicts: Sequence[dict] = json_dict.get('accounts', [])
@ -87,6 +123,7 @@ class Wallet:
return { return {
'version': WalletStorage.LATEST_VERSION, 'version': WalletStorage.LATEST_VERSION,
'name': self.name, 'name': self.name,
'preferences': self.preferences.data,
'accounts': [a.to_dict() for a in self.accounts] 'accounts': [a.to_dict() for a in self.accounts]
} }
@ -96,6 +133,7 @@ class Wallet:
@property @property
def hash(self) -> bytes: def hash(self) -> bytes:
h = sha256() h = sha256()
h.update(self.preferences.hash)
for account in sorted(self.accounts, key=attrgetter('id')): for account in sorted(self.accounts, key=attrgetter('id')):
h.update(account.hash) h.update(account.hash)
return h.digest() return h.digest()
@ -111,6 +149,27 @@ class Wallet:
decompressed = zlib.decompress(decrypted) decompressed = zlib.decompress(decrypted)
return json.loads(decompressed) 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: class WalletStorage:
@ -121,6 +180,7 @@ class WalletStorage:
self._default = default or { self._default = default or {
'version': self.LATEST_VERSION, 'version': self.LATEST_VERSION,
'name': 'My Wallet', 'name': 'My Wallet',
'preferences': {},
'accounts': [] 'accounts': []
} }

View file

@ -68,7 +68,7 @@ def set_logging(ledger_module, level, handler=None):
class Conductor: class Conductor:
def __init__(self, ledger_module=None, manager_module=None, verbosity=logging.WARNING, 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.ledger_module = ledger_module or get_ledger_from_environment()
self.manager_module = manager_module or get_manager_from_environment() self.manager_module = manager_module or get_manager_from_environment()
self.spv_module = get_spvserver_from_ledger(self.ledger_module) 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 = get_blockchain_node_from_ledger(self.ledger_module)
self.blockchain_node.segwit_enabled = enable_segwit self.blockchain_node.segwit_enabled = enable_segwit
self.spv_node = SPVNode(self.spv_module) 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) set_logging(self.ledger_module, verbosity)
@ -138,7 +140,7 @@ class Conductor:
class WalletNode: class WalletNode:
def __init__(self, manager_class: Type[BaseWalletManager], ledger_class: Type[BaseLedger], 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.manager_class = manager_class
self.ledger_class = ledger_class self.ledger_class = ledger_class
self.verbose = verbose self.verbose = verbose
@ -148,6 +150,7 @@ class WalletNode:
self.account: Optional[BaseAccount] = None self.account: Optional[BaseAccount] = None
self.data_path: Optional[str] = None self.data_path: Optional[str] = None
self.port = port self.port = port
self.default_seed = default_seed
async def start(self, spv_node: 'SPVNode', seed=None, connect=True): async def start(self, spv_node: 'SPVNode', seed=None, connect=True):
self.data_path = tempfile.mkdtemp() self.data_path = tempfile.mkdtemp()
@ -168,12 +171,12 @@ class WalletNode:
}) })
self.ledger = self.manager.ledgers[self.ledger_class] self.ledger = self.manager.ledgers[self.ledger_class]
self.wallet = self.manager.default_wallet self.wallet = self.manager.default_wallet
if seed is None and self.wallet is not None: if seed or self.default_seed:
self.wallet.generate_account(self.ledger)
elif self.wallet is not None:
self.ledger.account_class.from_dict( 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: else:
raise ValueError('Wallet is required.') raise ValueError('Wallet is required.')
self.account = self.wallet.default_account self.account = self.wallet.default_account

View file

@ -183,6 +183,7 @@ class AdvanceTimeTestCase(AsyncioTestCase):
class IntegrationTestCase(AsyncioTestCase): class IntegrationTestCase(AsyncioTestCase):
SEED = None
LEDGER = None LEDGER = None
MANAGER = None MANAGER = None
ENABLE_SEGWIT = False ENABLE_SEGWIT = False
@ -201,7 +202,7 @@ class IntegrationTestCase(AsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
self.conductor = Conductor( self.conductor = Conductor(
ledger_module=self.LEDGER, manager_module=self.MANAGER, verbosity=self.VERBOSITY, 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() await self.conductor.start_blockchain()
self.addCleanup(self.conductor.stop_blockchain) self.addCleanup(self.conductor.stop_blockchain)