timestamped top-level wallet preferences
This commit is contained in:
parent
c51cc02a87
commit
37ae302fc6
10 changed files with 211 additions and 158 deletions
|
@ -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=<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
|
||||
|
||||
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=<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
|
||||
|
||||
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 <password> [--data=<data>] [--encrypt-password=<encrypt_password>]
|
||||
[--wallet_id=<wallet_id>]
|
||||
[--wallet_id=<wallet_id>] [--blocking]
|
||||
|
||||
Options:
|
||||
--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
|
||||
from the decrypt password, used during password changes
|
||||
--wallet_id=<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),
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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': []
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue