diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 4b88f87d6e7d..8efacf22d729 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -26,7 +26,7 @@ from . import constants from .util import (log_exceptions, ignore_exceptions, chunks, OldTaskGroup, UnrelatedTransactionException, error_text_bytes_to_safe_str, AsyncHangDetector, - NoDynamicFeeEstimates, event_listener, EventListener) + NoDynamicFeeEstimates, event_listener, EventListener, RESERVED_OWNER_LIGHTNING) from . import transaction from .bitcoin import make_op_return, DummyAddress from .transaction import PartialTxOutput, match_script_against_template, Sighash @@ -960,12 +960,12 @@ async def wrapper(self: 'Peer', *args, **kwargs): change_addresses = [txout.address for txout in funding_tx.outputs() if wallet.is_change(txout.address)] for addr in change_addresses: - wallet.set_reserved_state_of_address(addr, reserved=True) + wallet.set_reserved_state_of_address(addr, reserved=True, owner=RESERVED_OWNER_LIGHTNING) try: return await func(self, *args, **kwargs) finally: for addr in change_addresses: - self.lnworker.wallet.set_reserved_state_of_address(addr, reserved=False) + self.lnworker.wallet.set_reserved_state_of_address(addr, reserved=False, owner=RESERVED_OWNER_LIGHTNING) return wrapper @temporarily_reserve_funding_tx_change_address diff --git a/electrum/plugin.py b/electrum/plugin.py index c497db5fbfbd..0510fb389f6b 100644 --- a/electrum/plugin.py +++ b/electrum/plugin.py @@ -47,7 +47,7 @@ from .version import ELECTRUM_VERSION from .i18n import _ from .util import (profiler, DaemonThread, UserCancelled, ThreadJob, UserFacingException, ChoiceItem, - make_dir, make_aiohttp_session) + make_dir, make_aiohttp_session, make_plugin_reservation_owner) from . import bip32 from . import plugins from .simple_config import SimpleConfig @@ -910,6 +910,23 @@ def get_storage(self, wallet: 'Abstract_Wallet') -> dict: plugin_storage = wallet.db.get_plugin_storage() return plugin_storage.setdefault(self.name, {}) + def reserve_wallet_address(self, wallet: 'Abstract_Wallet', addr: str, *, tag: Optional[str] = None) -> None: + """Reserve a wallet address so it is not handed out for new payment requests. + The reservation is owned by this plugin (optionally sub-categorised by `tag`), + persisted, and released automatically if the plugin is uninstalled.""" + wallet.set_reserved_state_of_address( + addr, reserved=True, owner=make_plugin_reservation_owner(self.name), tag=tag) + + def release_wallet_address(self, wallet: 'Abstract_Wallet', addr: str) -> None: + """Release a reservation previously made by this plugin (regardless of tag).""" + wallet.set_reserved_state_of_address( + addr, reserved=False, owner=make_plugin_reservation_owner(self.name)) + + def get_reserved_wallet_addresses(self, wallet: 'Abstract_Wallet', *, tag: Optional[str] = None) -> Sequence[str]: + """Return the addresses reserved by this plugin; if `tag` is given, only that + sub-category, otherwise all of this plugin's reservations.""" + return wallet.get_reserved_addresses(owner=make_plugin_reservation_owner(self.name), tag=tag) + class DeviceUnpairableError(UserFacingException): pass class HardwarePluginLibraryUnavailable(Exception): pass diff --git a/electrum/plugins/timelock_recovery/qt.py b/electrum/plugins/timelock_recovery/qt.py index 2d17a5eeb655..dbf58ce5ad80 100644 --- a/electrum/plugins/timelock_recovery/qt.py +++ b/electrum/plugins/timelock_recovery/qt.py @@ -101,6 +101,7 @@ def __init__(self, parent, config, name: str): @hook def load_wallet(self, wallet, window): + self._migrate_labeled_addresses(wallet) if self._init_qt_received: # only need/want the first signal return self._init_qt_received = True diff --git a/electrum/plugins/timelock_recovery/timelock_recovery.py b/electrum/plugins/timelock_recovery/timelock_recovery.py index 9d42cf44f0ca..0af25fb2abe1 100644 --- a/electrum/plugins/timelock_recovery/timelock_recovery.py +++ b/electrum/plugins/timelock_recovery/timelock_recovery.py @@ -6,15 +6,18 @@ from electrum.bitcoin import address_to_script from electrum.plugin import BasePlugin from electrum.transaction import PartialTxOutput, PartialTxInput, TxOutpoint -from electrum.util import bfh +from electrum.util import bfh, make_plugin_reservation_owner if TYPE_CHECKING: from electrum.gui.qt import ElectrumWindow from electrum.transaction import PartialTransaction, TxOutput from electrum.wallet import Abstract_Wallet +PLUGIN_NAME = "timelock_recovery" ALERT_ADDRESS_LABEL = "Timelock Recovery Alert Address" CANCELLATION_ADDRESS_LABEL = "Timelock Recovery Cancellation Address" +ALERT_ADDRESS_TAG = "alert" +CANCELLATION_ADDRESS_TAG = "cancellation" class PartialTxInputWithFixedNsequence(PartialTxInput): _fixed_nsequence: int @@ -54,30 +57,38 @@ def __init__(self, wallet: 'Abstract_Wallet'): self.wallet = wallet self.wallet_name = str(self.wallet) - def _get_address_by_label(self, label: str) -> str: - unused_addresses = list(self.wallet.get_unused_addresses()) - for addr in unused_addresses: - if self.wallet.get_label_for_address(addr) == label: + def _get_reserved_address(self, *, tag: str, label: str) -> str: + # the address is identified by its reservation (owner + tag); the label is only for display + owner = make_plugin_reservation_owner(PLUGIN_NAME) + # reuse a previously assigned address for this purpose, if it is still unused + for addr in self.wallet.get_reserved_addresses(owner=owner, tag=tag): + if not self.wallet.adb.is_used(addr): return addr - for addr in unused_addresses: - if not self.wallet.is_address_reserved(addr) and not self.wallet.get_label_for_address(addr): + # otherwise pick a fresh unused address, then reserve and label it + # (get_unused_addresses already excludes reserved addresses) + for addr in self.wallet.get_unused_addresses(): + if not self.wallet.get_label_for_address(addr): + self.wallet.set_reserved_state_of_address(addr, reserved=True, owner=owner, tag=tag) self.wallet.set_label(addr, label) return addr if self.wallet.is_deterministic(): addr = self.wallet.create_new_address(False) if addr: + self.wallet.set_reserved_state_of_address(addr, reserved=True, owner=owner, tag=tag) self.wallet.set_label(addr, label) return addr return '' def get_alert_address(self) -> str: if self._alert_address is None: - self._alert_address = self._get_address_by_label(ALERT_ADDRESS_LABEL) + self._alert_address = self._get_reserved_address( + tag=ALERT_ADDRESS_TAG, label=ALERT_ADDRESS_LABEL) return self._alert_address def get_cancellation_address(self) -> str: if self._cancellation_address is None: - self._cancellation_address = self._get_address_by_label(CANCELLATION_ADDRESS_LABEL) + self._cancellation_address = self._get_reserved_address( + tag=CANCELLATION_ADDRESS_TAG, label=CANCELLATION_ADDRESS_LABEL) return self._cancellation_address def make_unsigned_alert_tx(self, fee_policy) -> 'PartialTransaction': @@ -158,6 +169,22 @@ class TimelockRecoveryPlugin(BasePlugin): def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) + def _migrate_labeled_addresses(self, wallet) -> None: + # conversion from older wallets, bring addresses that earlier plugin versions tagged by labels + # into the reservation mechanism + if self.get_reserved_wallet_addresses(wallet): + return # already migrated + + tag_by_label = { + ALERT_ADDRESS_LABEL: ALERT_ADDRESS_TAG, + CANCELLATION_ADDRESS_LABEL: CANCELLATION_ADDRESS_TAG, + } + + for key, label in wallet.get_all_labels().items(): + tag = tag_by_label.get(label) + if tag is not None: + self.reserve_wallet_address(wallet, key, tag=tag) + @classmethod def json_checksum(cls, json_data: dict[str, Any]) -> str: # Assumes the values have a consistent json representation (not a key-value diff --git a/electrum/util.py b/electrum/util.py index afbf95df1a1b..e6703d97fc20 100644 --- a/electrum/util.py +++ b/electrum/util.py @@ -90,6 +90,58 @@ def all_subclasses(cls) -> Set: return res +# Addresses can be "reserved" so the wallet does not hand them out for new payment +# requests. Each reservation has an "owner" string ":" identifying who made it: +# - "plugin:" for plugins +# - "core:" for internal owners, e.g. "core:lightning" +# Only the owner may later release or re-reserve the address. A reservation may also carry an optional +# free-form `tag` that the owner can use to sub-categorise its own reservations. +# Plugin reservations are pruned when the plugin is uninstalled +# (see WalletDB.prune_uninstalled_plugin_reserved_addresses). +RESERVED_OWNER_NS_PLUGIN = "plugin" +RESERVED_OWNER_NS_CORE = "core" +RESERVED_OWNER_LIGHTNING = f"{RESERVED_OWNER_NS_CORE}:lightning" + + +class ReservedAddress(NamedTuple): + owner: str + tag: Optional[str] = None + + def serialize(self) -> str: + # a single colon-namespaced string "[:]" (owner is itself ":") + return f"{self.owner}:{self.tag}" if self.tag is not None else self.owner + + @classmethod + def deserialize(cls, s: str) -> 'ReservedAddress': + parts = s.split(":", 2) # owner is the first two components; anything after that is the tag + if len(parts) > 2: + return cls(owner=f"{parts[0]}:{parts[1]}", tag=parts[2]) + return cls(owner=s, tag=None) + + +def make_plugin_reservation_owner(plugin_name: str) -> str: + return f"{RESERVED_OWNER_NS_PLUGIN}:{plugin_name}" + + +def reservation_owner_namespace(owner: str) -> str: + return owner.split(":", 1)[0] + + +def plugin_name_from_reservation_owner(owner: str) -> str: + """Given a 'plugin:[:]' (serialized) string, return ''.""" + return owner.split(":")[1] + + +def reserved_addresses_from_stored(stored: Any) -> Dict[str, ReservedAddress]: + """Parse the persisted 'reserved_addresses' value ({addr: '[:]'}).""" + result = {} # type: Dict[str, ReservedAddress] + if isinstance(stored, dict): + for addr, v in stored.items(): + if isinstance(v, str) and v: + result[addr] = ReservedAddress.deserialize(v) + return result + + ca_path = certifi.where() diff --git a/electrum/wallet.py b/electrum/wallet.py index 70573de8c238..5af01d813146 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -55,7 +55,8 @@ WalletFileException, BitcoinException, InvalidPassword, format_time, timestamp_to_datetime, Satoshis, Fiat, TxMinedInfo, quantize_feerate, OrderedDictWithIndex, multisig_type, parse_max_spend, OnchainHistoryItem, read_json_file, write_json_file, UserFacingException, FileImportFailed, EventListener, - event_listener + event_listener, + RESERVED_OWNER_LIGHTNING, ReservedAddress, reserved_addresses_from_stored, ) from .bitcoin import COIN, is_address, is_minikey, relayfee, dust_threshold, DummyAddress, DummyAddressUsedInTxException from .keystore import ( @@ -436,7 +437,8 @@ def __init__(self, db: WalletDB, *, config: SimpleConfig): self.fiat_value = db.get_dict('fiat_value') self._receive_requests = db.get_dict('payment_requests') # type: Dict[str, Request] self._invoices = db.get_dict('invoices') # type: Dict[str, Invoice] - self._reserved_addresses = set(db.get('reserved_addresses', [])) + self._reserved_addresses = reserved_addresses_from_stored( + db.get('reserved_addresses', {})) # type: Dict[str, ReservedAddress] self._num_parents = db.get_dict('num_parents') self._freeze_lock = threading.RLock() # for mutating/iterating frozen_{addresses,coins} @@ -579,7 +581,9 @@ async def stop(self): finally: # even if we get cancelled if any([ks.is_requesting_to_be_rewritten_to_wallet_file for ks in self.get_keystores()]): self.save_keystore() - self.db.prune_uninstalled_plugin_data(self.config.get_installed_plugins()) + installed_plugins = self.config.get_installed_plugins() + self.db.prune_uninstalled_plugin_data(installed_plugins) + self.db.prune_uninstalled_plugin_reserved_addresses(installed_plugins) self.save_db() def is_up_to_date(self) -> bool: @@ -2242,25 +2246,58 @@ def set_frozen_state_of_coins( self.save_db() def is_address_reserved(self, addr: str) -> bool: - # note: atm 'reserved' status is only taken into consideration for 'change addresses' + # reserved addresses are withheld from new payment requests and from change selection. return addr in self._reserved_addresses - def set_reserved_state_of_address(self, addr: str, *, reserved: bool) -> None: + def set_reserved_state_of_address(self, addr: str, *, reserved: bool, owner: str, tag: Optional[str] = None) -> None: + """Reserve (or release) an address so it is not handed out for other uses, e.g. payment requests. + `owner` identifies who made the reservation, e.g. util.make_plugin_reservation_owner(plugin.name) + or util.RESERVED_OWNER_LIGHTNING. + `tag` is an optional free-form label the owner may use to sub-categorise its own reservations. + """ if not self.is_mine(addr): # silently ignore non-ismine addresses return with self.lock: - has_changed = (addr in self._reserved_addresses) != reserved + prev = self._reserved_addresses.get(addr) + if prev is not None and prev.owner != owner: + # owned by core or another plugin: refuse to take it over or to release it + self.logger.info( + f"ignoring reservation change for {addr} by {owner!r}: owned by {prev.owner!r}") + return if reserved: - self._reserved_addresses.add(addr) + new = ReservedAddress(owner=owner, tag=tag) + if prev == new: + return # nothing changed + self._reserved_addresses[addr] = new else: - self._reserved_addresses.discard(addr) - if has_changed: - self.db.put('reserved_addresses', list(self._reserved_addresses)) + if prev is None: + return # not reserved; nothing to do + self._reserved_addresses.pop(addr, None) + self._save_reserved_addresses() + + def _save_reserved_addresses(self) -> None: + self.db.put('reserved_addresses', + {addr: r.serialize() for addr, r in self._reserved_addresses.items()}) + + def get_reserved_addresses(self, *, owner: Optional[str] = None, tag: Optional[str] = None) -> Sequence[str]: + """Addresses currently reserved, optionally filtered by `owner` and/or `tag`.""" + with self.lock: + return [addr for addr, r in self._reserved_addresses.items() + if (owner is None or r.owner == owner) + and (tag is None or r.tag == tag)] + + def get_address_reservation_owner(self, addr: str) -> Optional[str]: + r = self._reserved_addresses.get(addr) + return r.owner if r is not None else None + + def get_address_reservation_tag(self, addr: str) -> Optional[str]: + r = self._reserved_addresses.get(addr) + return r.tag if r is not None else None def set_reserved_addresses_for_chan(self, chan: 'AbstractChannel', *, reserved: bool) -> None: for addr in chan.get_wallet_addresses_channel_might_want_reserved(): - self.set_reserved_state_of_address(addr, reserved=reserved) + self.set_reserved_state_of_address(addr, reserved=reserved, owner=RESERVED_OWNER_LIGHTNING) def can_export(self): return not self.is_watching_only() and hasattr(self.keystore, 'get_private_key') @@ -2815,7 +2852,10 @@ def check_address_for_corruption(self, addr: str) -> None: def get_unused_addresses(self) -> Sequence[str]: domain = self.get_receiving_addresses() - return [addr for addr in domain if not self.adb.is_used(addr) and not self.get_request_by_addr(addr)] + return [addr for addr in domain + if not self.adb.is_used(addr) + and not self.get_request_by_addr(addr) + and not self.is_address_reserved(addr)] @check_returned_address_for_corruption def get_unused_address(self) -> Optional[str]: @@ -2838,7 +2878,7 @@ def get_receiving_address(self) -> str: choice = domain[0] for addr in domain: if not self.adb.is_used(addr): - if self.get_request_by_addr(addr) is None: + if self.get_request_by_addr(addr) is None and not self.is_address_reserved(addr): return addr else: choice = addr diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index c20180a6d595..f418592f0829 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -35,7 +35,8 @@ from . import bitcoin from . import constants -from .util import profiler, WalletFileException, multisig_type, TxMinedInfo, MyEncoder +from .util import (profiler, WalletFileException, multisig_type, TxMinedInfo, MyEncoder, + reservation_owner_namespace, plugin_name_from_reservation_owner, RESERVED_OWNER_NS_PLUGIN) from .keystore import bip44_derivation from .transaction import Transaction, TxOutpoint, tx_from_any, PartialTransaction, PartialTxOutput, BadHeaderMagic from .logging import Logger @@ -71,7 +72,7 @@ def __init__(self, wallet_db: 'WalletDB'): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 71 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 72 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -259,6 +260,7 @@ def upgrade(self): self._convert_version_69() self._convert_version_70() self._convert_version_71() + self._convert_version_72() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1435,6 +1437,16 @@ def _convert_version_71(self): self.data['genesis_blockhash'] = constants.net.GENESIS self.data['seed_version'] = 71 + def _convert_version_72(self): + # 'reserved_addresses' changes from a flat list of addresses to a dict {addr: "ow:ner:tag"}. + # The existing entries were all written by lnpeer, so tag them with the core:lightning owner. + if not self._is_upgrade_method_needed(71, 71): + return + old = self.data.get('reserved_addresses', []) + if isinstance(old, list): + self.data['reserved_addresses'] = {addr: 'core:lightning' for addr in old} + self.data['seed_version'] = 72 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return @@ -2032,5 +2044,22 @@ def prune_uninstalled_plugin_data(self, installed_plugins: AbstractSet[str]) -> plugin_storage.pop(name) self.logger.info(f"deleting plugin data: {name=}") + def prune_uninstalled_plugin_reserved_addresses(self, installed_plugins: AbstractSet[str]) -> None: + """Release address reservations owned by plugins that are not installed anymore.""" + reserved = self.get('reserved_addresses', {}) + if not isinstance(reserved, dict): + return + changed = False + for addr in list(reserved.keys()): + stored = reserved[addr] # serialized "[:]" + if not isinstance(stored, str) or reservation_owner_namespace(stored) != RESERVED_OWNER_NS_PLUGIN: + continue + if plugin_name_from_reservation_owner(stored) not in installed_plugins: + reserved.pop(addr) + changed = True + self.logger.info(f"releasing reserved address of uninstalled plugin: {stored=}") + if changed: + self.put('reserved_addresses', reserved) + def set_keystore_encryption(self, enable): self.put('use_encryption', enable) diff --git a/tests/test_storage_upgrade.py b/tests/test_storage_upgrade.py index c20407ea630f..06e336816d28 100644 --- a/tests/test_storage_upgrade.py +++ b/tests/test_storage_upgrade.py @@ -323,7 +323,15 @@ async def test_upgrade_from_client_4_5_2_9dk_with_ln(self): # some labels, frozen addresses, saved local txs, invoices/requests, etc. The file also has partial writes. # Also, regression test for #8913 wallet_str = self._get_wallet_str() - await self._upgrade_storage(wallet_str) + db = await self._upgrade_storage(wallet_str) + # _convert_version_72: the old flat 'reserved_addresses' list becomes a {addr: "ow:ner[:tag]"} + # dict, marking existing entries with the core:lightning owner. + self.assertEqual(72, db.get('seed_version')) + reserved = db.get('reserved_addresses') + self.assertIsInstance(reserved, dict) + for addr in ("tb1qcmq7v2zg0jjy5g47k90fqd0h7a4mcyp7f3ly6r", + "tb1qgqfvg54gaads92a6dhwcgvvfjvnxdtq373guj5"): + self.assertEqual("core:lightning", reserved.get(addr)) ########## diff --git a/tests/test_wallet.py b/tests/test_wallet.py index de1a278a6ce1..65d818db0896 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -146,6 +146,123 @@ async def test_storage_prevouts_by_scripthash_persistence(self): self.assertEqual(PR_UNCONFIRMED, wallet1.get_invoice_status(pr)) +class TestReservedAddresses(WalletTestCase): + + def _make_wallet(self) -> Standard_Wallet: + text = 'cycle rocket west magnet parrot shuffle foot correct salt library feed song' + d = restore_wallet_from_text__for_unittest(text, path=self.wallet_path, config=self.config) + return d['wallet'] + + async def test_reserved_address_excluded_from_payment_requests(self): + wallet = self._make_wallet() + owner = util.make_plugin_reservation_owner("myplugin") + addr = wallet.get_unused_address() + self.assertIsNotNone(addr) + wallet.set_reserved_state_of_address(addr, reserved=True, owner=owner) + self.assertTrue(wallet.is_address_reserved(addr)) + # withheld from payment-request address handout + self.assertNotIn(addr, wallet.get_unused_addresses()) + self.assertNotEqual(addr, wallet.get_unused_address()) + # but get_receiving_address() is still guaranteed to return something + self.assertIsNotNone(wallet.get_receiving_address()) + # query helpers + self.assertEqual([addr], list(wallet.get_reserved_addresses(owner=owner))) + self.assertEqual(owner, wallet.get_address_reservation_owner(addr)) + # releasing makes it available again + wallet.set_reserved_state_of_address(addr, reserved=False, owner=owner) + self.assertFalse(wallet.is_address_reserved(addr)) + self.assertIn(addr, wallet.get_unused_addresses()) + await wallet.stop() + + async def test_reservations_are_persisted(self): + self.config.enable_plugin("myplugin") # so stop()'s prune keeps the plugin reservation + wallet = self._make_wallet() + addrs = list(wallet.get_unused_addresses()) + plugin_addr, ln_addr = addrs[0], addrs[1] + wallet.set_reserved_state_of_address( + plugin_addr, reserved=True, owner=util.make_plugin_reservation_owner("myplugin")) + wallet.set_reserved_state_of_address( + ln_addr, reserved=True, owner=util.RESERVED_OWNER_LIGHTNING) + # both the plugin and the core/lightning reservation are written to the db + self.assertEqual( + {plugin_addr: "plugin:myplugin", ln_addr: "core:lightning"}, + wallet.db.get('reserved_addresses')) + await wallet.stop() + # reload (this wallet has no lightning): both reservations survive purely via persistence + del wallet + wallet = Daemon._load_wallet(self.wallet_path, password=None, config=self.config) + self.assertTrue(wallet.is_address_reserved(plugin_addr)) + self.assertTrue(wallet.is_address_reserved(ln_addr)) + await wallet.stop() + + async def test_reservation_owner_is_enforced(self): + wallet = self._make_wallet() + # core/lightning reserves an address + addr = wallet.get_unused_address() + wallet.set_reserved_state_of_address(addr, reserved=True, owner=util.RESERVED_OWNER_LIGHTNING) + # a plugin can neither release it ... + wallet.set_reserved_state_of_address( + addr, reserved=False, owner=util.make_plugin_reservation_owner("foo")) + self.assertTrue(wallet.is_address_reserved(addr)) + self.assertEqual(util.RESERVED_OWNER_LIGHTNING, wallet.get_address_reservation_owner(addr)) + # ... nor take it over by re-reserving under its own name + wallet.set_reserved_state_of_address( + addr, reserved=True, owner=util.make_plugin_reservation_owner("foo")) + self.assertEqual(util.RESERVED_OWNER_LIGHTNING, wallet.get_address_reservation_owner(addr)) + + # one plugin cannot release another plugin's reservation + addr2 = wallet.get_unused_address() + self.assertNotEqual(addr, addr2) + wallet.set_reserved_state_of_address( + addr2, reserved=True, owner=util.make_plugin_reservation_owner("alice")) + wallet.set_reserved_state_of_address( + addr2, reserved=False, owner=util.make_plugin_reservation_owner("bob")) + self.assertEqual("plugin:alice", wallet.get_address_reservation_owner(addr2)) + # but the real owner can + wallet.set_reserved_state_of_address( + addr2, reserved=False, owner=util.make_plugin_reservation_owner("alice")) + self.assertFalse(wallet.is_address_reserved(addr2)) + await wallet.stop() + + async def test_tagged_reservations_and_owner_scoped_management(self): + wallet = self._make_wallet() + foo = util.make_plugin_reservation_owner("foo") + addr_a = wallet.get_unused_address() + wallet.set_reserved_state_of_address(addr_a, reserved=True, owner=foo, tag="alert") + addr_b = wallet.get_unused_address() + self.assertNotEqual(addr_a, addr_b) + wallet.set_reserved_state_of_address(addr_b, reserved=True, owner=foo, tag="cancellation") + # filter by owner returns all of foo's; by owner+tag returns the specific one + self.assertEqual({addr_a, addr_b}, set(wallet.get_reserved_addresses(owner=foo))) + self.assertEqual([addr_a], list(wallet.get_reserved_addresses(owner=foo, tag="alert"))) + self.assertEqual("alert", wallet.get_address_reservation_tag(addr_a)) + # owner and tag are serialized together as one colon-namespaced string + self.assertEqual( + {addr_a: "plugin:foo:alert", addr_b: "plugin:foo:cancellation"}, + wallet.db.get('reserved_addresses')) + # another plugin (different owner) cannot release foo's reservation ... + wallet.set_reserved_state_of_address( + addr_a, reserved=False, owner=util.make_plugin_reservation_owner("bar")) + self.assertTrue(wallet.is_address_reserved(addr_a)) + # ... but foo can release its own (tag is not needed to release) + wallet.set_reserved_state_of_address(addr_a, reserved=False, owner=foo) + self.assertFalse(wallet.is_address_reserved(addr_a)) + self.assertEqual({addr_b: "plugin:foo:cancellation"}, wallet.db.get('reserved_addresses')) + await wallet.stop() + + async def test_prune_uninstalled_plugin_reserved_addresses(self): + wallet = self._make_wallet() + addrs = list(wallet.get_unused_addresses()) + a_keep, a_drop = addrs[0], addrs[1] + wallet.set_reserved_state_of_address( + a_keep, reserved=True, owner=util.make_plugin_reservation_owner("installed_plugin")) + wallet.set_reserved_state_of_address( + a_drop, reserved=True, owner=util.make_plugin_reservation_owner("gone_plugin")) + wallet.db.prune_uninstalled_plugin_reserved_addresses({"installed_plugin"}) + self.assertEqual({a_keep: "plugin:installed_plugin"}, wallet.db.get('reserved_addresses')) + await wallet.stop() + + class FakeExchange(ExchangeBase): def __init__(self, rate): super().__init__(lambda self: None, lambda self: None)