Files
bal-electrum-plugin/bal/core/plugin_base.py
genspark-ai-developer[bot] b1c8bba9e9 fix: crash GUI su Windows (OverflowError anno 2038) — schede/menu BAL rotti (#3)
* fix(gui): voci di menu BAL duplicate/condensate dopo riavvio o cambio wallet

Sintomo (Windows 11): dopo aver riavviato Electrum o cambiato wallet, le
schede Will/Heirs sparivano dalla tab bar e dal menu, e compariva una voce
di menu condensata/illeggibile (icona + testo sovrapposti) sotto il logo di
Electrum, accanto a 'Portafogli'.

Causa: init_menubar_tools veniva eseguito DUE volte sulla stessa finestra.
Con il plugin gia abilitato, al riavvio Electrum invoca sia l'hook
init_menubar sia il percorso di init a caldo (init_qt -> _setup_window),
entrambi chiamano init_menubar_tools -> addTab/addAction duplicati.
Nell'originale init_qt faceva return (chiedendo il riavvio) e quindi i menu
venivano creati una sola volta; rimuovendo quel return (fix B3) e' emersa la
doppia inizializzazione.

Fix:
- BalWindow._menubar_initialized: guardia di idempotenza.
- init_menubar_tools: se gia inizializzato, esce subito (niente duplicati).
- on_close: resetta il flag dopo aver rimosso tab/azioni, cosi la stessa
  finestra puo essere riusata per un altro wallet.
- tests/gui_fixes_test.py: regressione che verifica la guardia in __init__,
  init_menubar_tools e on_close.

Logica di business invariata (nessuna modifica a bal/core/*).

* fix(gui): ripristina create_status_bar come no-op (come originale)

L'elemento di menu condensato/illeggibile sotto il logo di Electrum era
causato dal StatusBarButton aggiunto da create_status_bar.

Nell'originale Gitea questo hook aveva un 'return' subito dopo il log, PRIMA
di costruire il bottone: era quindi disabilitato di proposito. Durante la
pulizia del 'dead code' nel refactoring quel return era stato rimosso,
riattivando la creazione del bottone -> elemento icona+testo renderizzato
nel punto sbagliato dopo riavvio/cambio wallet.

Fix: create_status_bar torna a essere un no-op (return), fedele all'originale.
Le impostazioni restano raggiungibili da Strumenti -> Plugin.

Regressione: gui_fixes_test verifica che create_status_bar non chiami
addPermanentWidget.

* fix(core): OverflowError su Windows (anno 2038) che rompeva tab/menu BAL

CAUSA VERA (dal log Electrum dell'utente, Windows 11):

  OverflowError: Python int too large to convert to C int
    window.py __init__ -> create_heirs_tab -> WillSettingsWidget
    -> on_locktime_change -> BalTimestamp.to_date
    -> datetime.fromtimestamp(NLOCKTIME_MAX)

Su Windows time_t e' a 32 bit, quindi datetime.fromtimestamp() solleva
OverflowError per qualsiasi timestamp oltre il 2038 (es. NLOCKTIME_MAX =
2**32-1 = 4294967295, usato come locktime di default/sentinella). Su Linux
64-bit la stessa chiamata funziona: per questo il bug si vedeva solo su
Windows e i test su Linux non lo intercettavano.

L'eccezione interrompeva BalWindow.__init__ durante init_menubar/load_wallet,
lasciando le schede Will/Heirs e la voce di menu a meta' costruzione ->
l'elemento grafico condensato/illeggibile sotto il logo di Electrum.

FIX (comportamento invariato per tutti i valori normali):
- BalTimestamp._safe_fromtimestamp(): datetime.fromtimestamp con clamp a
  INT32_MAX in caso di OverflowError/OSError/ValueError, esattamente come la
  funzione get_max_allowed_timestamp() dell'originale (Electrum issue #6170).
- Usato in to_date / to_timestamp / __str__ / __repr__ di BalTimestamp.
- widgets.py set_value: usa il converter sicuro.
- util.py timestamp_minus: stessa protezione inline con clamp a INT32_MAX.

I valori entro il 2038 (date assolute normali, durate relative come 90d/5y)
producono lo stesso identico risultato di prima.

TEST: tests/windows_overflow_test.py riproduce il limite 32-bit di Windows
(monkeypatch di datetime.fromtimestamp) e dimostra che senza il fix si ottiene
lo stesso OverflowError del log, mentre col fix passa. Verificato anche che il
test FALLISCE senza il fix.

* docs(it): documenta il fix OverflowError Windows (anno 2038) nel changelog

Aggiunge la sezione §13 al CHANGELOG_REFACTOR.md che descrive:
- sintomo (schede/menu rotti su Windows dopo riavvio/cambio wallet)
- causa vera dal log (datetime.fromtimestamp(NLOCKTIME_MAX) -> OverflowError
  su time_t 32-bit di Windows)
- fix con _safe_fromtimestamp (clamp a INT32_MAX, come Electrum #6170)
- test di regressione windows_overflow_test.py
Aggiornata anche la cronologia (§12) con PR #3.

---------

Co-authored-by: GenSpark AI Developer <ai@genspark.dev>
2026-06-28 22:51:28 -04:00

380 lines
15 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 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__)
# --------------------------------------------------------------------------- #
# Wallet-DB registration
# --------------------------------------------------------------------------- #
# Electrum needs to know how to (de)serialise the custom dictionaries the
# plugin stores inside the wallet file. ``register_dict`` associates a key
# name 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.
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
json_db.register_dict("heirs", tuple, None)
json_db.register_dict("will", dict, None)
json_db.register_dict("will_settings", lambda x: x, None)
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.
"""
_version = None
__version__ = "0.2.8" # AUTOMATICALLY GENERATED DO NOT EDIT
# 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)
def version(self):
"""Return the plugin version, read once from the ``VERSION`` file."""
if not self._version:
try:
f = ""
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
f = str(fi.read())
self._version = f.strip()
except Exception as e:
_logger.error(f"failed to get version: {e}")
self._version = "unknown"
return self._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)
self.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90)
self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7)
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)
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
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)
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 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
@staticmethod
def default_will_settings():
"""Default will settings: a fee rate plus absolute threshold/locktime."""
will_settings = {"baltx_fees": 100}
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:
* ``"<n>y"`` -> ``n`` years (unit ``"y"``)
* ``"<n>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}"