Files
bal-electrum-plugin/bal/core/plugin_base.py
bitcoinafterlife 9c654bf2bf i18n phases 1+2: BAL translation layer and translatable texts
Phase 1: new bal/i18n.py, BAL's own gettext layer (domain "bal", catalogs
read with plugin.read_file()). _() asks Electrum's catalog first, then
BAL's, then returns the English source. The Qt plugin loads the catalog of
Electrum's GUI language at start-up; the CLI stays English.

Phase 2: every user-visible GUI text is now a whole, extractable sentence
(Ruff INT rules enabled). Class-level texts are marked with N_() and
translated when shown. Stored data stays language-neutral: the status
history is written in English and translated for display, the calendar
defaults follow the GUI language, and the history label and wallet labels
are never translated because BAL uses them to recognise its transactions.

No visible change apart from the double colon fixed in the will detail.
See CHANGELOG entries 58 and 59 and PLAN_I18N.md.
2026-09-26 21:54:50 +02:00

611 lines
27 KiB
Python

"""
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 datetime, timedelta, timezone
from electrum import constants, json_db
from electrum.logging import get_logger
from electrum.plugin import BasePlugin
from electrum.transaction import tx_from_any
from electrum.util import classproperty
from ..i18n import N_, _
_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, # pyright: ignore[reportMissingImports]
)
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) # pyright: ignore[reportAttributeAccessIssue]
_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.
``translatable=True`` is for a default *text* (marked with ``N_()``) that
should follow the GUI language. The English default is what gets stored,
and :meth:`get` translates it when it is read, so the stored value never
depends on the GUI language. A text the user wrote is returned unchanged.
Only for texts that are shown: a text that is also used to recognise
stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable.
"""
def __init__(self, config, name, default, translatable=False):
self.config = config
self.name = name
self.default = default
self.translatable = translatable
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
if self.translatable and v == self.default:
return _(v)
return v
def localized_default(self):
"""Return the default value, translated if the setting is translatable."""
return _(self.default) if self.translatable else self.default
def set(self, value, save=True):
"""Persist ``value`` for this key."""
if self.translatable and value == self.localized_default():
# The translated default is stored as its English original, so
# it keeps following the GUI language (see get()).
value = self.default
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", ...).
# Must be a classproperty (not a plain class attribute) because the class
# is defined before constants.net is set to the correct network — a plain
# attribute would capture "bitcoin" and never update.
@classproperty
def chainname(cls):
return 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.
# Deliberately NOT translatable: Util._label_matches_history() uses
# this text to recognise BAL's own local transactions (stale-history
# cleanup, spendable UTXOs). A label that changed with the GUI
# language would no longer match the transactions saved before.
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)
# REBUILD_ON_CLOSE: when enabled (default), closing the wallet or
# quitting Electrum runs the "Build your will" wizard
# (BalBuildWillDialog) to rebuild and re-validate the will. When
# disabled, on_close() only persists the current in-memory willitems to
# the wallet DB: no rebuild dialog, no auto-sign/broadcast, no
# invalidation prompts at close. Default ON.
self.REBUILD_ON_CLOSE = BalConfig(config, "bal_rebuild_on_close", True)
# AUTO_REBUILD: when enabled, an incoming/outgoing wallet transaction
# automatically re-runs the same rebuild flow the wizard runs at
# wallet close (anticipate the delivery date by one day to orphan the
# previous will; build an on-chain invalidation tx ONLY when the
# anticipated locktime would fall before the check-alive threshold or
# the threshold is already in the past). When disabled (default) the
# will is only rebuilt when the user presses Check / Prepare or closes
# the wallet. Default OFF.
self.AUTO_REBUILD = BalConfig(config, "bal_auto_rebuild", False)
# 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)
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
# per QR frame when exporting/importing a will through the QR channel.
# The settings dialog offers the 4 standard presets of
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
# Default 150 (small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
# 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/"
)
# Calendar (.ics) texts: only shown, never used to recognise data, so
# their defaults follow the GUI language (translatable=True). The
# $tokens must survive translation.
self.EVENT_DESCRIPTION = BalConfig(
config,
"bal_event_description",
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"),
translatable=True,
)
self.EVENT_SUMMARY = BalConfig(
config,
"bal_event_summary",
N_("BAL -Will execution of $wallet_name"),
translatable=True,
)
# Default will-executor servers, keyed by network. These addresses are
# the ones currently reported by each server's <chain>/info endpoint and
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
# (bcrt1...) addresses, which are invalid on those networks.
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": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True,
}
},
"testnet4": {
"https://we.bitcoin-after.life": {
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"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: dict[str, float] = {"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 = datetime.now(tz=timezone.utc).date()
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
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:
* ``"<n>y"`` -> ``n`` years (unit ``"y"``)
* ``"<n>d"`` -> ``n`` days (unit ``"d"``)
* an integer -> an absolute UNIX timestamp (``unit is None``)
"""
value: int
unit: str | None
def __init__(self, value):
self.value = 1
self.unit = None
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, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
try:
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
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(tz=timezone.utc)
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}"