diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ba51b..0f03561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/bal/VERSION b/bal/VERSION index 79a2734..167b000 100644 --- a/bal/VERSION +++ b/bal/VERSION @@ -1 +1 @@ -0.5.0 \ No newline at end of file +0.5.4 \ No newline at end of file diff --git a/bal/__init__.py b/bal/__init__.py index 9416262..3093135 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -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" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index cdbcc3e..b31d854 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -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 = { diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 6a37e4e..6c39420 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -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( diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 7f5a93b..eb89178 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -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 diff --git a/bal/manifest.json b/bal/manifest.json index 54da086..131986b 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -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", diff --git a/tests/test_group_i_basic_checkalive.py b/tests/test_group_i_basic_checkalive.py new file mode 100644 index 0000000..c5131e0 --- /dev/null +++ b/tests/test_group_i_basic_checkalive.py @@ -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.")