This commit is contained in:
2026-07-02 00:29:26 +02:00
parent 868674ece2
commit ca874d8e93
8 changed files with 500 additions and 40 deletions

View File

@@ -1541,3 +1541,259 @@ delivered together in one version.
**Outcome:** DONE (delivered as test ZIP v0.4.8; commit only after the user
confirms the ZIP works).
---
## 26. v0.5.1 - Dynamic Check Alive in BASIC mode + Windows dialog-flicker fix
**Date:** 2026-07-01
**Goal:** Fix two owner-reported bugs: (1) in BASIC mode, anticipating the
delivery time (locktime) to a date earlier than the hidden, stale "Check
Alive" (threshold) default could incorrectly mark the will as expired /
blocked; (2) on Windows only, BAL dialogs (Settings, waiting dialogs) briefly
flashed 1-2 blank "ghost" windows before rendering their real content.
**What changed:**
- **Bug 1 - BASIC mode Check Alive now tracks the delivery time.**
`bal/gui/qt/window.py`
- Added `BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS` (2 hours,
owner-approved) and a new pure `BalWindow.compute_date_to_check(
is_basic_mode, locktime_setting, threshold_setting)` static method.
- In BASIC mode, `date_to_check` (the "Check Alive" reference timestamp) is
now derived LIVE from the current delivery time (`will_settings
["locktime"]`), placed 2 hours before it, instead of reading the hidden,
possibly stale stored `threshold`. This guarantees `date_to_check` is
always earlier than the delivery time, so anticipating/postponing the
delivery date in the wizard never triggers the "locktime is lower than
threshold" guard (`build_inheritance_transaction`) nor
`Will.check_will_expired`/`WillExpiredException` anymore.
- In ADVANCED mode nothing changes: the stored `threshold` is used exactly
as before.
- Root-cause note: `Util.parse_locktime_string` never raises on invalid
input, it silently returns `0`; `compute_date_to_check` now explicitly
treats that sentinel as "unparsable" and falls back to the stored
threshold, so BASIC mode can never compute a bogus near-epoch value.
- `init_class_variables()` now simply calls `compute_date_to_check(...)`.
- **Bug 2 - Windows dialog flicker fixed.**
`bal/gui/qt/window_utils.py` (`show_modal`) and `bal/gui/qt/dialogs.py`
(`BalWaitingDialog.exe`): `bring_to_front()` (`raise_()` +
`activateWindow()`) was being called on dialogs BEFORE `exec()` actually
showed them. On Windows this forces Qt to create the dialog's native window
handle immediately, empty and unpositioned, causing a brief flash of 1-2
blank windows before the real content was drawn (reproduced from a
screen recording; Linux does not show this, since native window creation
there is lazier). Fixed by scheduling `bring_to_front()` via
`QTimer.singleShot(0, ...)` so it runs on the next event-loop iteration,
i.e. after the dialog is already visible with its real content - the
focus/raise behaviour is unchanged, only the timing of when it fires.
- Added `tests/test_group_i_basic_checkalive.py` with 6 tests calling the
real `BalWindow.compute_date_to_check` directly (no GUI/wallet needed):
the 2-hour offset constant, a fresh "1y" default, the exact reported
"anticipate to 1 month" scenario, a sweep of delivery dates always staying
before locktime, ADVANCED mode being unaffected, and the unparsable-input
fallback (which caught and fixed a real edge case during development: a
0-timestamp sentinel instead of an exception).
- Version bumped 0.5.0 -> 0.5.1 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- Full test suite: `272 passed` (266 previous + 6 new), plus the same 2
pre-existing, unrelated failures already present before this change
(`test_core_plugin_base.py::test_default_will_settings` /
`test_validate_will_settings`, expecting the old `baltx_fees=100` default
that v0.5.0 already changed to `20`).
- `ruff`: no new errors (only pre-existing star-import / F841 noise).
- The Check Alive fix (Bug 1) is fully covered by automated tests. The
Windows flicker fix (Bug 2) was diagnosed from a screen recording and
reasoned from the Qt/Windows native-window-creation behaviour; it cannot be
reproduced or automatically verified on this Linux environment, so the
owner needs to confirm on Windows that the flicker is gone.
**Outcome:** DONE (delivered as test ZIP v0.5.1; commit only after the owner
confirms both fixes work, especially the Windows flicker one).
---
## 27. v0.5.2 - Real fix for the Windows Settings-dialog flicker (root cause found by diffing against v0.4.8)
**Date:** 2026-07-01
**Goal:** The v0.5.1 attempt at fixing the Windows-only "ghost window" flicker
on the BAL Settings dialog (deferring `bring_to_front()` via
`QTimer.singleShot`) did NOT fix it, per owner confirmation after testing on
Windows. This entry documents the corrected root cause and the actual fix.
**Root cause (confirmed by diff, not guesswork):** the owner provided the
last known-good release (v0.4.8, no flicker) for direct comparison.
`bal/gui/qt/window_utils.py` (`show_modal`/`bring_to_front`) turned out to be
byte-identical between v0.4.8 and v0.5.0 (aside from the v0.5.1 attempt),
proving that code was never the cause. Diffing `bal/gui/qt/plugin.py`
instead showed that `settings_dialog()` gained, between v0.4.8 and v0.5.0:
(a) ~17 ADVANCED-only setting rows that are shown/hidden via `setVisible()`
right at dialog construction time (to reflect the current BASIC/ADVANCED
mode before the dialog is ever shown), and (b)
`outer.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)`, which did
**not** exist anywhere in v0.4.8's GUI code. `SetFixedSize` forces Qt to
recompute and enforce the dialog's exact size on every layout invalidation -
including while those ~17 rows are being hidden for the first time, before
the dialog has ever been shown. On Windows, that live geometry renegotiation
overlapping with native window creation is what produced the blank "ghost"
window flash (reproduced from the owner's screen recording).
**What changed:**
- `bal/gui/qt/plugin.py` (`settings_dialog`): removed
`outer.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)`, restoring
the sizing behaviour v0.4.8 already had (no forced constraint). Added an
explicit `d.adjustSize()` right before `show_modal(d)` so the dialog still
opens at its natural, correctly laid-out size (now computed once, instead
of being continuously re-enforced). The dynamic BASIC/ADVANCED row
visibility feature itself is fully preserved - only the size-constraint
coupling that caused the flicker was removed.
- Removed the now-unused `QLayout` import.
**Verification:**
- Full test suite: `272 passed`, same 2 pre-existing, unrelated failures as
before (stale `baltx_fees=100` expectation vs the v0.5.0 default of `20`).
- `ruff`: no new errors (only pre-existing star-import noise).
- Diagnosis confirmed by direct code comparison against v0.4.8 (provided by
the owner) rather than inference alone. The fix itself still cannot be
verified on this Linux environment (Windows-only symptom): the owner needs
to confirm on Windows that the flicker is gone.
**Outcome:** DONE (delivered as test ZIP v0.5.2; commit only after the owner
confirms the flicker is actually gone this time).
---
## 28. v0.5.3 - Windows Settings-dialog flicker: corrected fix (drop bring_to_front before exec(), not just defer it)
**Date:** 2026-07-01
**Goal:** v0.5.2 (removing `SetFixedSize`) did NOT fix the Windows flicker
either, per owner confirmation ("è come prima" - unchanged). This entry
documents the corrected fix, based on new evidence from the owner: the
flicker only ever happens with the Settings dialog in ADVANCED mode (many
more visible rows, much larger dialog) and never in BASIC mode (few rows,
small dialog) - always with the same intensity, not intermittent.
**Root cause (corrected):** `show_modal()` (`bal/gui/qt/window_utils.py`)
called `bring_to_front(dialog)` (`raise_()` + `activateWindow()`) BEFORE
`dialog.exec()`. The v0.5.1 attempt only *deferred* this call by one event
loop tick (`QTimer.singleShot(0, ...)`) instead of removing it, which turned
out to still fire too early relative to Windows finishing the dialog's
native window layout/paint - reproducing the same flicker. The owner's
BASIC-vs-ADVANCED observation is the key evidence: `raise_()`/
`activateWindow()` forcing native window creation before the layout has
settled produces a flash whose visible severity scales with how different
the dialog's final size is from its premature/unlaid-out state - which is
exactly why a much bigger ADVANCED-mode dialog flickers noticeably while the
small BASIC-mode one does not.
**What changed:**
- `bal/gui/qt/window_utils.py` (`show_modal`): removed the `bring_to_front()`
call (deferred or otherwise) entirely. `QDialog.exec()` already shows,
raises and activates a **modal** dialog on its own as part of entering its
modal event loop, so the call was always redundant for this case - and, on
Windows, actively harmful. `bring_to_front()` itself is untouched and is
still used (correctly) by `show_on_top()`, for the few genuinely
**non-modal** dialogs that use `.show()` instead of `.exec()` and do need
an explicit raise/activate.
- Removed the now-unused `QTimer` import.
- `bal/gui/qt/dialogs.py` (`BalWaitingDialog.exe`): applied the same
corrected fix for consistency (same redundant-before-exec() pattern, same
underlying cause), even though the owner did not report flicker there -
removed `bring_to_front()`/`QTimer.singleShot` and kept only `self.exec()`.
**Verification:**
- Full test suite: `272 passed`, same 2 pre-existing, unrelated failures as
before.
- `ruff`: no new errors (only pre-existing star-import / F841 noise).
- As with v0.5.1/v0.5.2, this is a Windows-only symptom that cannot be
reproduced or verified on this Linux environment; the owner needs to
confirm on Windows.
**Outcome:** DONE (delivered as test ZIP v0.5.3; commit only after the owner
confirms the flicker is actually gone).
---
## 29. v0.5.4 - REAL fix for the Windows Settings-dialog flicker (found by Windows-side bisection with the owner)
**Date:** 2026-07-02
**Goal:** v0.5.3 (dropping `bring_to_front()` before `exec()`) did NOT fix the
flicker either, per owner confirmation ("è come prima" - unchanged). This
entry documents the actual root cause, found through a structured bisection
process where the owner tested a series of isolated diagnostic ZIPs directly
on Windows (since the bug cannot be reproduced on Linux), and the
corresponding real fix, **confirmed working by the owner on Windows**.
**Bisection process (each step tested live on Windows by the owner):**
1. A dialog stripped down to almost nothing (2 checkboxes, no ADVANCED-only
rows, none of the widgets/layout features added since v0.4.8) did **not**
flicker → the cause is in the dialog's content, not in how/where it is
opened, nor in `show_modal`/`bring_to_front` (already ruled out in
v0.5.1/v0.5.3), nor in `SetFixedSize` (already ruled out in v0.5.2).
2. Re-adding *only* the "User Type" combo + its dynamic show/hide mechanism
flickered (mildly); re-adding *only* the new text/input fields (Event
summary/description, Welist Server, Calendar app) did **not** → narrowed
to the combo/visibility mechanism.
3. A plain `QComboBox` alone (no confirmation dialog, no dynamic show/hide)
did **not** flicker; the construction-time `setVisible()` toggle alone
(no combo at all) **did** flicker → isolated to the visibility mechanism
itself.
4. A version of that same toggle that hides each ADVANCED-only widget
**before** adding it to the layout (instead of adding it visible and
hiding it afterwards) did **not** flicker → fix validated.
**Root cause:** the ~17 ADVANCED-only rows (Welist Server, Number of
reminders, Event summary/description, Calendar app, Auto-sign, and their
per-row reset buttons) were added to the settings grid **visible**, then all
hidden together with `setVisible(False)` in BASIC mode, in a single loop
*after* they were already part of the live layout. On Windows, that
visible→hidden transition inside an already-populated layout - happening
while the dialog's native window was still being created - forced a
re-layout that flashed the dialog on screen. It only showed in ADVANCED mode
because BASIC mode is when the widgets get hidden (so ADVANCED is when they
stay visible and the dialog is at its largest, making any transient
mis-layout most noticeable) - consistent with the owner's observation that
severity tracked dialog size.
**What changed:**
- `bal/gui/qt/plugin.py` (`settings_dialog`): added a `basic_init` flag and a
`_hide_if_basic(widget)` helper right after the dialog is created. Every
ADVANCED-only widget is now wrapped in `_hide_if_basic(...)` at its
`grid.addWidget(...)` call, so its final visibility is set **before** it
ever joins the layout - it never transitions visible→hidden inside a live
layout. Removed the old post-hoc loop that used to hide all ~17 widgets at
once after they were already added. The runtime BASIC/ADVANCED toggle
(`on_user_type_change`, used while the dialog is already open and visible)
is unaffected and still works exactly as before - only the one-time
construction-time visibility set-up changed.
- Reverted the ineffective v0.5.2 attempt (`SetFixedSize` removal /
`adjustSize()`) and the ineffective v0.5.3 attempt (removing
`bring_to_front()` from `show_modal()`/`BalWaitingDialog.exe()`) back to
their original v0.5.0 behaviour, since bisection proved neither was the
cause; keeping them would only have added unnecessary risk/diff.
**Verification:**
- Full test suite: `272 passed`, same 2 pre-existing, unrelated failures as
before (stale `baltx_fees=100` expectation vs the v0.5.0 default of `20`).
- `ruff`: no new errors (only pre-existing star-import noise).
- **Confirmed fixed by the owner on Windows** (the actual reporting
environment), after testing the full real Settings dialog with this fix
applied - not just the isolated diagnostic build.
**Outcome:** DONE. Confirmed resolved by the owner on Windows.

View File

@@ -1 +1 @@
0.5.0
0.5.4

View File

@@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing
``json_db.register_dict``) and PyQt6.
"""
__version__ = "0.5.0"
__version__ = "0.5.4"

View File

@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
"""
_version = None
__version__ = "0.5.0" # AUTOMATICALLY GENERATED DO NOT EDIT
__version__ = "0.5.4" # AUTOMATICALLY GENERATED DO NOT EDIT
# Command used to open an .ics calendar file, per operating system.
default_app = {

View File

@@ -378,6 +378,33 @@ class Plugin(BalPlugin):
lbl_logo = QLabel()
lbl_logo.setPixmap(qicon)
# WINDOWS FLICKER FIX (root cause found by bisection with the owner
# testing on Windows): the ADVANCED-only rows used to be added to the
# grid *visible* and then hidden all at once with setVisible(False)
# AFTER they were already in the layout (see the old block near the
# end of this function). On Windows that visible->hidden transition,
# happening inside an already-populated layout while the dialog's
# native window is being created, forced a live re-layout that flashed
# the dialog on screen (the "ghost window" the owner saw). It only
# showed in ADVANCED because that is the larger dialog. The fix,
# validated step by step on Windows, is to set each ADVANCED-only
# widget's visibility BEFORE it is ever added to the layout, so it
# never transitions visible->hidden inside a live layout.
#
# ``basic_init`` is the current mode; ``_hide_if_basic(w)`` hides a
# widget immediately (at creation time) when in BASIC mode and returns
# it, so it can be wrapped around each ADVANCED-only widget inline.
basic_init = str(self.USER_TYPE.get()).lower() != "advanced"
def _hide_if_basic(w):
"""Hide *w* now (before it is added to any layout) if in BASIC
mode, and return it. Used to give ADVANCED-only widgets their
final visibility up-front, avoiding the Windows relayout flicker
that a later setVisible(False) inside the populated grid caused."""
if basic_init:
w.setVisible(False)
return w
# heir_ping_willexecutors = BalCheckBox(self.PING_WILLEXECUTORS)
# heir_ask_ping_willexecutors = BalCheckBox(self.ASK_PING_WILLEXECUTORS)
# heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
@@ -562,13 +589,13 @@ class Plugin(BalPlugin):
"The wallet password is requested only if the wallet is "
"encrypted."
)
grid.addWidget(lbl_auto_sign, 2, 0)
grid.addWidget(heir_auto_sign, 2, 1)
grid.addWidget(help_auto_sign, 2, 2)
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
reset_btn_auto_sign = _make_reset_btn(
self.AUTO_SIGN, heir_auto_sign, "check"
)
grid.addWidget(reset_btn_auto_sign, 2, 3)
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
add_widget(
grid,
"Panel editable Date and Fee",
@@ -630,11 +657,11 @@ class Plugin(BalPlugin):
"How many reminder alarms the exported calendar (.ics) event "
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
)
grid.addWidget(lbl_num_reminders, 6, 0)
grid.addWidget(heir_num_reminders, 6, 1)
grid.addWidget(help_num_reminders, 6, 2)
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
grid.addWidget(reset_btn_6, 6, 3)
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
lbl_event_summary = QLabel(_("Event summary"))
help_event_summary = HelpButton(
@@ -644,11 +671,11 @@ class Plugin(BalPlugin):
" $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode."
)
grid.addWidget(lbl_event_summary, 7, 0)
grid.addWidget(edit_event_summary, 7, 1)
grid.addWidget(help_event_summary, 7, 2)
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
grid.addWidget(reset_btn_7, 7, 3)
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
lbl_event_description = QLabel(_("Event description"))
help_event_description = HelpButton(
@@ -658,11 +685,11 @@ class Plugin(BalPlugin):
" $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode."
)
grid.addWidget(lbl_event_description, 8, 0)
grid.addWidget(edit_event_description, 8, 1)
grid.addWidget(help_event_description, 8, 2)
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
grid.addWidget(reset_btn_8, 8, 3)
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3)
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
# factory default is always used and the setting is hidden.
lbl_welist_server = QLabel(_("Welist Server URL"))
@@ -670,11 +697,11 @@ class Plugin(BalPlugin):
"URL of the server that provides the will-executor list. "
"Only available in ADVANCED mode."
)
grid.addWidget(lbl_welist_server, 9, 0)
grid.addWidget(edit_welist_server, 9, 1)
grid.addWidget(help_welist_server, 9, 2)
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
grid.addWidget(reset_btn_9, 9, 3)
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
lbl_calendar_app = QLabel(_("Calendar app command"))
help_calendar_app = HelpButton(
@@ -682,23 +709,18 @@ class Plugin(BalPlugin):
"Leave empty to use the system default (xdg-open/open/start).\n"
"Only used in ADVANCED mode."
)
grid.addWidget(lbl_calendar_app, 10, 0)
grid.addWidget(edit_calendar_app, 10, 1)
grid.addWidget(help_calendar_app, 10, 2)
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
grid.addWidget(reset_btn_10, 10, 3)
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3)
# Initial visibility: hidden in basic, visible in advanced.
basic_init = str(self.USER_TYPE.get()).lower() != "advanced"
for w in (lbl_welist_server, edit_welist_server, help_welist_server,
lbl_num_reminders, heir_num_reminders, help_num_reminders,
lbl_event_summary, edit_event_summary, help_event_summary,
lbl_event_description, edit_event_description, help_event_description,
lbl_calendar_app, edit_calendar_app, help_calendar_app,
lbl_auto_sign, heir_auto_sign, help_auto_sign,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_auto_sign):
w.setVisible(not basic_init)
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
# correct initial visibility inline (via _hide_if_basic) BEFORE being
# added to the grid. The old code did the opposite - it added them
# visible and then hid them here, all at once, which is what caused
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
# setVisible() loop here.
grid.addWidget(heir_repush, 11, 0)
grid.addWidget(

View File

@@ -451,11 +451,74 @@ class BalWindow:
for heir, value in updates.items():
self.heirs[heir] = value
# How long BEFORE the delivery time (locktime) the auto-computed Check
# Alive threshold is placed in BASIC mode (see compute_date_to_check).
# Owner-approved value: 2 hours. Kept as a named constant so the offset
# is documented in one place and easy to tune later if needed.
BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS = 2 * 60 * 60
@staticmethod
def compute_date_to_check(is_basic_mode, locktime_setting, threshold_setting):
"""Resolve the "Check Alive" reference timestamp (``date_to_check``).
SIMPLE / ADVANCED: the "Check Alive" (threshold) field is hidden in
BASIC mode, so the user can never keep it in sync with the delivery
time (locktime) when they anticipate/postpone it from the wizard.
Left untouched, the threshold stays at its old/default value (e.g.
"today + 11 months") and, if the user later sets a delivery date
EARLIER than that, two checks elsewhere (the "locktime is lower than
threshold" guard in ``build_inheritance_transaction``, and
``Will.check_will_expired`` via ``date_to_check``) misfire and
block/expire the will - even though the postpone/expiry check itself
is meant to be inert in BASIC mode (see the ``is_basic_mode()`` guard
in ``init_class_variables``).
FIX: in BASIC mode only, ignore the stored threshold and instead
derive ``date_to_check`` LIVE from the current delivery time
(``locktime_setting``), placed a small, fixed offset BEFORE it
(``BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS`` = 2 hours). This keeps
``date_to_check`` always < locktime by construction, so anticipating
or postponing the delivery date in BASIC mode never triggers a
spurious "expired" / "threshold" error. In ADVANCED mode nothing
changes: the stored threshold is used as-is, exactly like before.
Args:
is_basic_mode: ``BalPlugin.is_basic_mode()`` result.
locktime_setting: the current ``will_settings["locktime"]`` value
(relative string like ``"1y"`` or an absolute timestamp).
threshold_setting: the current ``will_settings["threshold"]``
value, used as-is in ADVANCED mode and as a fallback if the
locktime cannot be parsed.
Returns:
float: the resolved ``date_to_check`` UNIX timestamp.
"""
if is_basic_mode:
try:
locktime_ts = Util.parse_locktime_string(locktime_setting)
# Util.parse_locktime_string never raises: on anything it
# cannot parse it silently returns 0 (see bal/core/util.py).
# Treat that sentinel the same as a parse error, otherwise we
# would compute a nonsensical date_to_check near the UNIX
# epoch instead of falling back to the stored threshold.
if locktime_ts:
return (
locktime_ts
- BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
)
except Exception:
pass
return BalTimestamp(threshold_setting).to_timestamp()
def init_class_variables(self):
if not self.heirs:
raise NoHeirsException(_("Heirs are not defined"))
try:
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
self.date_to_check = BalWindow.compute_date_to_check(
self.bal_plugin.is_basic_mode(),
self.will_settings["locktime"],
self.will_settings["threshold"],
)
# found = False
# NOTE: block-height tracking removed (A1) - locktimes are always
# UNIX timestamps now, so we no longer read the current block height

View File

@@ -1,7 +1,7 @@
{
"name": "bal",
"fullname": "Bitcoin After Life",
"version": "0.5.0",
"version": "0.5.4",
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
"author": "Svatantrya",
"licence": "MIT",

View File

@@ -0,0 +1,119 @@
"""
Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
Background (reported by the owner): with the plugin left in BASIC mode (the
default), the "Check Alive" (threshold) field is hidden from the user and
stays stuck at its old/default value (roughly "today + 11 months", i.e. one
year minus the default 30-day margin). If the user then anticipates the
delivery time (locktime) to something earlier than that stale threshold - e.g.
"in 1 month" - two different checks that compare locktime against
``date_to_check`` (the resolved threshold) would incorrectly treat the will as
"expired"/"invalid", even though the whole Check Alive concept is supposed to
be inert in BASIC mode.
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it,
so it is always < locktime by construction. In ADVANCED mode nothing changes:
the stored threshold is used as-is.
These tests call the real production method directly (no GUI/Electrum wallet
needed - it is a plain ``@staticmethod``), so they exercise the exact code
used at runtime rather than a re-implementation.
Run:
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q
"""
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
def test_offset_is_two_hours():
"""Owner-approved margin: exactly 2 hours."""
assert OFFSET == 2 * 60 * 60
def _relative_days_to_midnight_timestamp(days):
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N
days", truncated to midnight. Used to compute the expected value in
tests without duplicating the parsing logic itself."""
from datetime import datetime, timedelta
now = datetime.now()
return (
(now + timedelta(days=days))
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
)
def test_basic_mode_relative_locktime_one_year():
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier."""
result = BalWindow.compute_date_to_check(True, "1y", "30d")
expected_locktime = _relative_days_to_midnight_timestamp(365)
assert abs(result - (expected_locktime - OFFSET)) < 5
def test_basic_mode_anticipated_delivery_stays_consistent():
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
NOT the old ~11-month threshold - this is the exact bug scenario
reported by the owner."""
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
result = BalWindow.compute_date_to_check(
True, anticipated_locktime, stale_threshold
)
locktime_ts = _relative_days_to_midnight_timestamp(30)
# date_to_check must be (delivery - 2h), always strictly before delivery.
assert result < locktime_ts
assert abs((locktime_ts - OFFSET) - result) < 5
def test_basic_mode_date_to_check_always_before_locktime():
"""Regression guard for the reported bug: whatever the delivery date is
(even very close to "now"), date_to_check must stay before it."""
for relative_locktime in ("1d", "7d", "30d", "90d", "365d"):
result = BalWindow.compute_date_to_check(True, relative_locktime, "30d")
parsed_locktime = _relative_days_to_midnight_timestamp(
int(relative_locktime[:-1])
)
assert result < parsed_locktime, (
f"date_to_check ({result}) should be before locktime "
f"({parsed_locktime}) for locktime={relative_locktime}"
)
def test_advanced_mode_uses_stored_threshold_unchanged():
"""ADVANCED mode: behaviour must stay exactly as before this fix - the
stored threshold is used as-is, regardless of the locktime value."""
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
result = BalWindow.compute_date_to_check(False, "30d", absolute_threshold)
assert abs(result - absolute_threshold) < 1
def test_basic_mode_falls_back_on_unparsable_locktime():
"""If the locktime can't be parsed for any reason, BASIC mode must not
crash: it falls back to the stored threshold, same as ADVANCED."""
absolute_threshold = time.time() + 5 * 86400
result = BalWindow.compute_date_to_check(
True, {"not": "a valid locktime"}, absolute_threshold
)
assert abs(result - absolute_threshold) < 1
if __name__ == "__main__":
test_offset_is_two_hours()
test_basic_mode_relative_locktime_one_year()
test_basic_mode_anticipated_delivery_stays_consistent()
test_basic_mode_date_to_check_always_before_locktime()
test_advanced_mode_uses_stored_threshold_unchanged()
test_basic_mode_falls_back_on_unparsable_locktime()
print("All test_group_i_basic_checkalive tests passed.")