""" bal.core.plugin_base ===================== GUI-agnostic foundation of the plugin. It contains: * :class:`BalConfig` - a thin typed wrapper around an Electrum config key with a default value. * :class:`BalPlugin` - the base plugin class (extends Electrum's ``BasePlugin``) holding every configuration option and the default "will settings". The Qt-specific ``Plugin`` subclass lives in ``bal.gui.qt.plugin``. * :class:`BalTimestamp`- helper to convert between relative durations (``"30d"``, ``"1y"``) and absolute timestamps. It also registers the three custom persisted dictionaries (``heirs``, ``will`` and ``will_settings``) with Electrum's JSON database so they are serialised together with the wallet file. This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. """ import json import os import platform from datetime import date, datetime, timedelta from electrum import constants, json_db from electrum.logging import get_logger from electrum.plugin import BasePlugin from electrum.transaction import tx_from_any _logger = get_logger(__name__) # --------------------------------------------------------------------------- # # Plugin version - single source of truth # --------------------------------------------------------------------------- # # The version lives ONLY in bal/manifest.json (the file Electrum itself reads). # We used to hardcode it in four files and keep them in sync with a pre-commit # hook; reading it from the manifest removes that duplication. # # importlib.resources is used on purpose: it reads a data file bundled inside # the ``bal`` package and works identically whether the plugin runs from an # extracted directory or from INSIDE a zip (Electrum loads external plugins via # zipimport). It never builds a path by hand, so there is no os.path.join # backslash issue on Windows inside a zip. _VERSION_CACHE = None def get_version(): """Return the plugin version from ``bal/manifest.json`` (cached). Zip-safe and independent of the current working directory. Falls back to ``"unknown"`` if the manifest cannot be read, so importing the plugin never fails just because of version lookup. """ global _VERSION_CACHE if _VERSION_CACHE is None: try: import importlib.resources _parent_pkg = __package__.rpartition(".")[0] if __package__ else "bal" data = ( importlib.resources.files(_parent_pkg) .joinpath("manifest.json") .read_text(encoding="utf-8") ) _VERSION_CACHE = json.loads(data)["version"] except Exception as e: # noqa: BLE001 - never break import over version _logger.error(f"failed to read version from manifest.json: {e}") _VERSION_CACHE = "unknown" return _VERSION_CACHE # --------------------------------------------------------------------------- # # Wallet-DB registration # --------------------------------------------------------------------------- # # Electrum needs to know how to (de)serialise the custom dictionaries the plugin # stores inside the wallet file: a key name is associated with a conversion # callable applied to each value when the wallet is loaded. ``will`` values run # through ``get_will`` so the stored transaction hex is turned back into a # ``Transaction`` object. # # COMPATIBILITY NOTE (Electrum 4.8.0): # Electrum 4.8.0 REMOVED ``json_db.register_dict`` and moved the registration API # to the new ``electrum.stored_dict`` module, switching from flat key names to # '/'-separated paths with a wildcard for the dict entries, and swapping the last # two arguments: # # Electrum <= 4.7.2 : json_db.register_dict(NAME, method, _type) # Electrum >= 4.8.0 : stored_dict.register_name('/NAME/*', _type, method) # # The conversion semantics are identical in both versions (``_type is dict`` -> # ``method(**value)``, ``_type is tuple`` -> ``method(*value)``, otherwise # ``method(value)``); only the registration API changed. We therefore detect # which API is available and adapt, so the plugin keeps working on BOTH 4.7.2 and # 4.8.0 instead of breaking for users who have not upgraded yet. def get_will(x): """Deserialise a stored will entry, rebuilding its ``tx`` object.""" try: x["tx"] = tx_from_any(x["tx"]) except Exception as e: raise e return x try: # Electrum >= 4.8.0 from electrum.stored_dict import register_name as _electrum_register_name def _register_will_dict(name, method, _type=None): """Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API).""" _electrum_register_name(f"/{name}/*", _type, method) except ImportError: # Electrum <= 4.7.2 def _register_will_dict(name, method, _type=None): """Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API).""" json_db.register_dict(name, method, _type) _register_will_dict("heirs", tuple) _register_will_dict("will", dict) _register_will_dict("will_settings", lambda x: x) class BalConfig: """Typed accessor for a single Electrum configuration key. Wraps ``config.get`` / ``config.set_key`` and supplies a default value when the key is missing. """ def __init__(self, config, name, default): self.config = config self.name = name self.default = default def get(self, default=None): """Return the stored value, falling back to ``default`` then ``self.default``.""" v = self.config.get(self.name, default) if v is None: if default is not None: v = default else: v = self.default return v def set(self, value, save=True): """Persist ``value`` for this key.""" self.config.set_key(self.name, value, save=save) class BalPlugin(BasePlugin): """Base plugin: holds configuration and default inheritance settings. The GUI layer subclasses this in ``bal.gui.qt.plugin.Plugin`` and adds the Electrum ``@hook`` methods. Keeping the configuration here means the CLI layer (or unit tests) can use the plugin logic without importing Qt. """ # Command used to open an .ics calendar file, per operating system. default_app = { "Linux": "xdg-open", "Windows": "cmd /c start", "Darwin": "open", } # Human-readable chain name ("bitcoin", "testnet", "regtest", ...). chainname = ( constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin" ) # Default geometry hint for some dialogs (kept from the original code). SIZE = (159, 97) @property def version(self): """Plugin version, read from ``bal/manifest.json`` (single source of truth). See :func:`get_version`.""" return get_version() def __init__(self, parent, config, name): self.logger = get_logger(__name__) BasePlugin.__init__(self, parent, config, name) # Base directory for plugin data inside the Electrum data dir. self.base_dir = os.path.join(config.electrum_path(), "bal") self.plugin_dir = os.path.split(os.path.realpath(__file__))[0] # Make the plugin importable when loaded from a zip (legacy behaviour: # the parent directory of this file is added to ``sys.path``). zipfile = "/".join(self.plugin_dir.split("/")[:-1]) import sys sys.path.insert(0, zipfile) self.parent = parent self.config = config self.name = name # ---------------------------------------------------------------- # # Configuration options (all persisted via Electrum's config). # ---------------------------------------------------------------- # self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True) self.BROADCAST = BalConfig(config, "bal_broadcast", True) self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90) # NOTE (A1): block-height locktimes were removed; the plugin now uses # only timestamp-based locktimes. LOCKTIME_BLOCKS is therefore no longer # read anywhere in the code. It is kept here (dormant) on purpose, to # avoid touching a persisted config key ("bal_locktime_blocks") that may # already exist in some users' saved settings. self.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90) self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7) # NOTE (A1): same as LOCKTIME_BLOCKS above - block-height locktimes were # removed, so LOCKTIMEDELTA_BLOCKS is no longer read anywhere. It is kept # here (dormant) on purpose, to avoid touching the persisted config key # "bal_locktimedelta_blocks" that may already exist in saved settings. self.LOCKTIMEDELTA_BLOCKS = BalConfig( config, "bal_locktimedelta_blocks", 144 * 7 ) self.ENABLE_MULTIVERSE = BalConfig(config, "bal_enable_multiverse", False) self.TX_FEES = BalConfig(config, "bal_tx_fees", 100) self.INVALIDATE = BalConfig(config, "bal_invalidate", True) self.ASK_INVALIDATE = BalConfig(config, "bal_ask_invalidate", True) self.PREVIEW = BalConfig(config, "bal_preview", True) self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) # SAVE_HISTORY (history persistence): when enabled, the valid will # transactions are saved into the wallet's LOCAL history (the History # tab) after every check, each with a configurable label. Default ON. self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True) # HISTORY_LABEL: label text applied to the will transactions saved into # the wallet's local history. May contain the "{willexecutor}" token, # which is replaced with the will-executor URL of each will item at # save time. self.HISTORY_LABEL = BalConfig( config, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}", ) # AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after # querying the will-executor servers, automatically sign the will # transactions and broadcast them to their will-executors, without the # user having to invoke "Sign" and "Broadcast" separately. The wallet # password is requested only when the wallet is actually encrypted # (handled by BalWindow.get_wallet_password). Default ON. self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True) # EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and # check-alive date fields are editable everywhere (toolbar / Heirs tab), # not only inside the "Build your will" wizard. Default OFF, so the dates # stay display-only outside the wizard unless the user opts in. self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the # exported .ics calendar should contain. Each reminder becomes its own # VEVENT (its own date in the calendar). The dates are spread uniformly # across the check-alive period and the LAST one always falls one day # before the delivery deadline (locktime). Default 3; the settings # dialog caps it at 5 and the date builder additionally limits it to at # most one event per available day. self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3) # "Add transaction without will-executor" backup tx. Default OFF # (False): a fresh wallet does NOT create the extra no-will-executor # backup transaction (the "azure" tx), so a plain inheritance has no # backup tx unless the user explicitly enables it from the wizard. The # chosen value is persisted per wallet, so reopening the plugin always # follows what is saved in that wallet (the default only applies when no # value has been stored yet, i.e. new wallets). self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False) self.MAX_WILLEXECUTOR_FEE = BalConfig( config, "bal_max_willexecutor_fee", 500000 ) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True) # SIMPLE / ADVANCED mode (global, plugin-wide). # # "basic" -> SIMPLE mode (DEFAULT): hides advanced controls (the # Raw/Date selector and the "Check Alive" field/icon) and # disables the "check alive" postpone behaviour, so the # plugin is easier for non-technical users. # "advanced" -> shows every control (the original behaviour). # # This is stored in Electrum's GLOBAL config (not in the wallet file), # so it never affects compatibility with existing wallets: an old wallet # simply opens with whatever global value is set, and its owner can # switch to "advanced" from the plugin settings whenever they want. self.USER_TYPE = BalConfig(config, "bal_user_type", "basic") self.WELIST_SERVER = BalConfig( config, "bal_welist_server", "https://welist.bitcoin-after.life/" ) self.EVENT_DESCRIPTION = BalConfig( config, "bal_event_description", "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", ) self.EVENT_SUMMARY = BalConfig( config, "bal_event_summary", "BAL -Will execution of $wallet_name" ) # Default will-executor servers, keyed by network. self.WILLEXECUTORS = BalConfig( config, "bal_willexecutors", { "mainnet": { "https://we.bitcoin-after.life": { "base_fee": 100000, "status": "New", "info": "Bitcoin After Life Will Executor", "address": "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf", "selected": True, } }, "testnet": { "https://we.bitcoin-after.life": { "base_fee": 100000, "status": "New", "info": "Bitcoin After Life Will Executor", "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "selected": True, } }, "testnet4": { "https://we.bitcoin-after.life": { "base_fee": 100000, "status": "New", "info": "Bitcoin After Life Will Executor", "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "selected": True, } }, "regtest": { "https://we.bitcoin-after.life": { "base_fee": 100000, "status": "New", "info": "Bitcoin After Life Will Executor", "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "selected": True, } }, }, ) self.WILL_SETTINGS = BalConfig( config, "bal_will_settings", BalPlugin.default_will_settings(), ) self.system = platform.system() self.CALENDAR_APP = BalConfig( config, "bal_open_app", self.default_app.get(self.system, "") ) # Cached toggles used by the GUI list filters. self._hide_invalidated = self.HIDE_INVALIDATED.get() self._hide_replaced = self.HIDE_REPLACED.get() def resource_path(self, *parts): """Absolute path to a file bundled inside the plugin directory.""" return os.path.join(self.plugin_dir, *parts) def sync_hide_filters(self): """Re-read the "hide" filter flags from the persisted config. The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by the GUI list to decide which rows to skip. They can be changed from two different places: * the list toolbar buttons, which call :meth:`hide_invalidated` / :meth:`hide_replaced` (a toggle that updates both the cache and the config), and * the Settings dialog checkboxes, which write the config directly (``BalConfig.set``) without touching the cached flags. In the second case the cache and the config would drift apart and the transaction list would keep filtering with the *old* value, so the toggled rows never appear/disappear until Electrum is restarted. Re-syncing the cache from the config here (called by ``update_all``) keeps every code path coherent regardless of where the change came from. """ self._hide_invalidated = self.HIDE_INVALIDATED.get() self._hide_replaced = self.HIDE_REPLACED.get() def hide_invalidated(self): """Toggle (and persist) the "hide invalidated transactions" filter.""" self._hide_invalidated = not self._hide_invalidated self.HIDE_INVALIDATED.set(self._hide_invalidated) def hide_replaced(self): """Toggle (and persist) the "hide replaced transactions" filter.""" self._hide_replaced = not self._hide_replaced self.HIDE_REPLACED.set(self._hide_replaced) def validate_will_settings(self, will_settings): """Fill in any missing will-setting with its default value.""" defaults = BalPlugin.default_will_settings() if not will_settings: will_settings = [] if int(will_settings.get("baltx_fees", 0)) < 1: will_settings["baltx_fees"] = defaults['baltx_fees'] if not will_settings.get("threshold"): will_settings["threshold"] = defaults['threshold'] if not will_settings.get("locktime"): will_settings["locktime"] = defaults['locktime'] return will_settings def is_basic_mode(self): """Return True when the plugin runs in SIMPLE ("basic") mode. Centralises the USER_TYPE check so the GUI never compares the raw string in many places. Anything other than the explicit "advanced" value is treated as basic, so the safe/simple behaviour is the default even if the stored value is missing or unexpected. """ return str(self.USER_TYPE.get()).lower() != "advanced" @staticmethod def default_will_settings(): """Default will settings: a fee rate plus absolute threshold/locktime.""" will_settings = {"baltx_fees": 20} will_settings.update(BalPlugin.default_will_settings_absolute()) return will_settings @staticmethod def default_will_settings_absolute(): """Convert the default relative dates into absolute timestamps (from today).""" relative_dates = BalPlugin.default_will_settings_relative() today = date.today() dt = datetime(today.year, today.month, today.day, 0, 0, 0) threshold = ( dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) ).timestamp() locktime = ( dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days()) ).timestamp() return {"threshold": threshold, "locktime": locktime} @staticmethod def default_will_settings_relative(): """Default relative dates: 30 days threshold, 1 year locktime.""" return {"threshold": "30d", "locktime": "1y"} class BalTimestamp: """Parse and convert relative durations / absolute timestamps. A value may be: * ``"y"`` -> ``n`` years (unit ``"y"``) * ``"d"`` -> ``n`` days (unit ``"d"``) * an integer -> an absolute UNIX timestamp (``unit is None``) """ value = None unit = None def __init__(self, value): str_value = str(value) if str_value and str_value[-1].lower() in ("y", "d"): self.value = int(str_value[:-1]) self.unit = str_value[-1] else: try: self.value = int(value) except Exception as _e: self.value = 1 self.unit = None def duration_to_days(self): """Return the duration expressed in days (years are ``*365``).""" return self.value * 365 if self.unit == 'y' else self.value @staticmethod def _safe_fromtimestamp(ts): """``datetime.fromtimestamp`` that never raises ``OverflowError``. On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp`` raises ``OverflowError: Python int too large to convert to C int`` for any timestamp past the year-2038 limit (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, used as the default/sentinel locktime). On 64-bit Linux the same call succeeds, which is why this only crashed on the user's Windows build. We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170). """ int32_max = 2 ** 31 - 1 try: return datetime.fromtimestamp(ts) except (OSError, OverflowError, ValueError): try: return datetime.fromtimestamp(min(int(ts), int32_max)) except (OSError, OverflowError, ValueError): return datetime.fromtimestamp(int32_max) def to_date(self, from_date=None, reverse=False): """Resolve to a ``datetime``. For absolute values the stored timestamp is returned; for relative ones the duration is added to (or, if ``reverse``, subtracted from) ``from_date`` (defaulting to *now*), normalised to midnight. """ if self.unit is None: return self._safe_fromtimestamp(self.value) else: if from_date is None: from_date = datetime.now() if isinstance(from_date, (int, float)): from_date = self._safe_fromtimestamp(from_date) reverse = 1 if not reverse else -1 try: return ( from_date + (reverse * timedelta(days=self.duration_to_days())) ).replace(hour=0, minute=0, second=0, microsecond=0) except (OverflowError, OSError, ValueError): # Duration overflowed datetime's range; clamp to INT32_MAX. return self._safe_fromtimestamp(2 ** 31 - 1).replace( hour=0, minute=0, second=0, microsecond=0 ) def to_timestamp(self, from_date=None, reverse=False): """Same as :meth:`to_date` but returns a UNIX timestamp.""" return self.to_date(from_date, reverse).timestamp() def __str__(self): if self.unit is None: return self._safe_fromtimestamp(self.value).isoformat() else: return f"{self.value}{self.unit}" def __repr__(self): if self.unit is None: return self._safe_fromtimestamp(self.value).isoformat() else: return f"{self.value}{self.unit}"