Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions electrum/lnpeer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion electrum/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions electrum/plugins/timelock_recovery/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 36 additions & 9 deletions electrum/plugins/timelock_recovery/timelock_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions electrum/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<namespace>:<name>" identifying who made it:
# - "plugin:<plugin_name>" for plugins
# - "core:<subsystem>" 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>[:<tag>]" (owner is itself "<namespace>:<name>")
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:<name>[:<tag>]' (serialized) string, return '<name>'."""
return owner.split(":")[1]


def reserved_addresses_from_stored(stored: Any) -> Dict[str, ReservedAddress]:
"""Parse the persisted 'reserved_addresses' value ({addr: '<owner>[:<tag>]'})."""
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()


Expand Down
66 changes: 53 additions & 13 deletions electrum/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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]:
Expand All @@ -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
Expand Down
33 changes: 31 additions & 2 deletions electrum/wallet_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "<owner>[:<tag>]"
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)
Loading