diff --git a/CHANGELOG.md b/CHANGELOG.md index 879a394..b0863e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -784,3 +784,72 @@ the DATA mode and the RAW mode, while keeping the **CHECK ALIVE** title in bold. **Outcome:** DONE (delivered as a test ZIP v0.3.8 for the user to try before commit). + +## 15. UI batch (v0.3.9): clearer "will expired" message, History labels, settings checkbox, wizard button + +**Context:** A batch of four small UI improvements requested by the user +(internally tracked as TASK A/B/C/D). + +**Changes:** +- **(A) "Will expired" message — clearer and not alarming.** Rewrote the message + raised by `Will.check_will_expired()` (`bal/core/will.py`): the will id is now + shortened (first 8 + last 8 chars, e.g. `9f1b0a75…fed9ae1b`) and the locktime + is shown as a readable UTC date (e.g. `2026-06-22 11:00 UTC`) instead of a raw + UNIX timestamp, with an explanation that the will will be invalidated and + re-signed. Two small helpers were added (`_short_will_id`, `_format_locktime`) + and `datetime`/`timezone` are now imported at module top. In the "Build your + will" wizard (`bal/gui/qt/dialogs.py`) an expired will is now shown in the + WARNING colour (orange) instead of the ERROR colour (red), because it is an + expected part of the flow, not an error. +- **(B) History tab labels (text only).** Renamed the labels written to + Electrum's History tab: inheritance transactions now read + `BAL Inheritance transaction` (was `BAL Transaction`, updated in both + `dialogs.py` and `window.py`) and the invalidate transaction now reads + `BAL Invalidate transaction` (was `BAL Invalidate`, in `window.py`). Colours + are left to Electrum's defaults (outgoing transactions are shown in red by + Electrum itself) to keep the change simple and robust. +- **(C) "No will-executor TX" checkbox in plugin settings.** Added a checkbox to + the settings dialog (`bal/gui/qt/plugin.py`) bound to the existing + `NO_WILLEXECUTOR` config (default ON), the same config used by the wizard's + will-executor download window, so the two stay in sync. Its help button reads: + "Create a will that does not require a Will-executor; it can be saved, for + example, on a USB stick, and a copy can be given to the heirs." It is also + included in the "Reset setting" action so a reset restores it to ON. +- **(D) Wizard button label.** The main wizard button now reads `Build Your Will` + (was `Create your will`, `bal/gui/qt/lists.py`), matching the tooltip and the + rest of the codebase which already call it the "Build your will" wizard. + +- **(A2) Two-line expired message.** Following user feedback that the orange + message was too long on a single line, a `
` line break was added in + `Will.check_will_expired()` so the second sentence ("too late to anticipate, + the will will be invalidated and re-signed.") is shown on its own line. The + message is rendered as HTML by `msg_warning()`, so the `
` tag is honoured. +- **(A3) Auto-invalidate regression fix when adding an heir to an expired will.** + While verifying TASK A a regression was found: when an heir was added to an + already-expired will through the wizard, the automatic invalidation window no + longer opened (the user had to restart Electrum or press Check). Root cause: + in `dialogs.py::task_phase1`, adding an heir first raises + `HeirNotFoundException` (so the will is rebuilt by `build_will()`), and the + freshly rebuilt transactions are themselves expired, raising + `WillExpiredException` on the INNER `check_will()`. The original handler there + did `return False, None`, which never triggered invalidation. The fix makes + the wizard, in that case, behave exactly like the "Tools -> Invalidate" menu: + instead of trying to auto-open the invalidation transaction window (which + proved unreliable — depending on the OS window manager the transaction window + ended up BEHIND the main wallet window when the wizard closed, and an + automatic re-check loop could ask to invalidate repeatedly before the + invalidation tx reached the mempool), the wizard now closes and shows a clear + instruction popup telling the user to run `Tools -> Invalidate` themselves and + then press `Check`. That menu path is already known to work perfectly (its + transaction window stays in front and sets the `BAL Invalidate transaction` + history label) and it makes the user consciously aware of this deliberate, + important action. The reasoning behind this choice is documented in the code. + +**Verification:** +- `py_compile` on all changed files: OK. +- `ruff check`: no new errors (only the pre-existing star-import noise and + pre-existing F841 warnings, none in the changed lines). +- Full test suite: `239 passed`. + +**Outcome:** DONE (delivered as test ZIP v0.3.9, confirmed OK by the user before +commit). diff --git a/bal/VERSION b/bal/VERSION index 6678432..940ac09 100644 --- a/bal/VERSION +++ b/bal/VERSION @@ -1 +1 @@ -0.3.8 +0.3.9 diff --git a/bal/__init__.py b/bal/__init__.py index 1b76402..e47175e 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.3.8" +__version__ = "0.3.9" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 58eefb8..4ae308e 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -91,7 +91,7 @@ class BalPlugin(BasePlugin): """ _version = None - __version__ = "0.3.8" # AUTOMATICALLY GENERATED DO NOT EDIT + __version__ = "0.3.9" # AUTOMATICALLY GENERATED DO NOT EDIT # Command used to open an .ics calendar file, per operating system. default_app = { diff --git a/bal/core/will.py b/bal/core/will.py index 6ead271..4c49314 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -27,6 +27,7 @@ The status flags themselves (the source of truth) stay here; only the mapping """ import copy +from datetime import datetime, timezone from electrum.i18n import _ from electrum.logging import Logger, get_logger @@ -652,6 +653,47 @@ class Will: _logger.info("will ok") return True + @staticmethod + def _short_will_id(will_id): + """Return a human-friendly, shortened form of a will id (hash). + + Will ids are long hex strings (e.g. a 64-char txid) that are hard to + read in a message box. This keeps only the first and last 8 characters + joined by an ellipsis (e.g. ``9f1b0a75…fed9ae1b``). Short ids are left + untouched. + + Args: + will_id: The will identifier (hash) to shorten; coerced to ``str``. + + Returns: + str: The shortened id, or the original string if it is short. + """ + text = str(will_id) + # Only shorten when there is something to gain (>= 20 chars), otherwise + # the ellipsis form would not actually be shorter or clearer. + if len(text) <= 20: + return text + return f"{text[:8]}\u2026{text[-8:]}" + + @staticmethod + def _format_locktime(locktime): + """Format a UNIX locktime timestamp as a readable UTC date string. + + Args: + locktime: UNIX timestamp (seconds) to format. + + Returns: + str: A string like ``2026-06-22 11:00 UTC``. If formatting fails + for any reason, the raw timestamp is returned as a fallback so the + caller always has something to show. + """ + try: + dt = datetime.fromtimestamp(int(locktime), tz=timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M UTC") + except Exception: + # Never let date formatting break the (already exceptional) flow. + return str(locktime) + @staticmethod def check_will_expired(all_inputs_min_locktime, timestamp_to_check): """Raise WillExpiredException if any valid transaction has expired. @@ -659,6 +701,12 @@ class Will: Locktimes are always UNIX timestamps, so a transaction is expired when its locktime is in the past relative to ``timestamp_to_check``. + When a will is expired the message is written to be reassuring rather + than alarming: being past the locktime is an EXPECTED situation that the + plugin handles by invalidating the old will and re-signing it. The + message therefore uses a shortened will id and a human-readable UTC date + instead of raw values. + Args: all_inputs_min_locktime: Mapping prevout -> will-item info, used to find the minimum locktime per input. @@ -671,12 +719,27 @@ class Will: locktime = int(wid[0][1].tx.locktime) # Locktimes are always timestamps: expired when in the past. if locktime < int(timestamp_to_check): + # Build a clear, non-technical message: short id + + # readable date, and explain what will happen next. + short_id = Will._short_will_id(wid[0][0]) + when = Will._format_locktime(locktime) + # The message is rendered as HTML by msg_warning(), + # so a
tag forces a line break: the second + # sentence goes on its own line to avoid an overly + # long single line in the wizard. raise WillExpiredException( - f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}" + "Will expired (id {id}, locktime {when}) \u2014
" + "too late to anticipate, the will will be " + "invalidated and re-signed.".format( + id=short_id, when=when + ) ) else: - from datetime import datetime - _logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}") + _logger.debug( + f"Will Not Expired {wid[0][0]}: " + f"{Will._format_locktime(locktime)} > " + f"{Will._format_locktime(timestamp_to_check)}" + ) # def check_all_input_spent_are_in_wallet(): # _logger.info("check all input spent are in wallet or valid txs") diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index fa62ab3..83a2eb5 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -648,13 +648,41 @@ class BalBuildWillDialog(BalDialog): self.bal_window.check_will() for wid in Will.only_valid(self.bal_window.willitems): - self.bal_window.wallet.set_label(wid, "BAL Transaction") + # Label shown in Electrum's History tab for inheritance txs. + self.bal_window.wallet.set_label(wid, "BAL Inheritance transaction") self.msg_set_building(self.msg_ok()) except WillExecutorNotPresent: self.msg_set_status( _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR ) + except WillExpiredException as e: + # An expired will is an EXPECTED situation (the locktime has + # passed). After adding/changing an heir the will is rebuilt + # above (build_will), and the freshly rebuilt transactions can + # themselves already be expired. + # + # We must NOT trigger the wizard's automatic invalidation loop + # here (return None, invalidate_tx). That loop re-runs + # task_phase1 right after broadcasting the invalidation, but the + # invalidation tx is not yet visible in the mempool, so the will + # is still detected as expired and the user is asked to + # invalidate again and again (infinite loop). It also never sets + # the "BAL Invalidate transaction" history label. + # + # Instead we reproduce EXACTLY what the "Tools -> invalidate" + # menu does (BalWalletWindow.invalidate_will): open Electrum's + # classic transaction dialog so the user can sign and broadcast + # the invalidation manually, set the proper history label, and + # stop. This is robust regardless of mempool confirmation state. + # + # The actual call to invalidate_will() (which opens GUI windows) + # must run in the GUI thread, so we only RETURN a signal here + # ("invalidate_classic"); on_success_phase1 performs the call. + # We still show the expired notice as a WARNING (orange). + self.msg_set_building(self.msg_warning(e)) + return "invalidate_classic", None + except Exception as e: self.msg_set_building(self.msg_error(e)) return False, None @@ -906,6 +934,54 @@ class BalBuildWillDialog(BalDialog): # if not tx: # self.msg_edit_row(self.msg_error("Error, no tx was built")) # return + + # Special signal raised by task_phase1 when the freshly rebuilt will is + # already expired (e.g. an heir was added to an expired will). Instead of + # running the wizard's automatic invalidation loop (which would re-check + # before the invalidation tx reaches the mempool and loop forever), we + # behave exactly like the "Tools -> invalidate" menu: open Electrum's + # classic transaction dialog so the user signs and broadcasts the + # invalidation manually, with the "BAL Invalidate transaction" label. + # This runs in the GUI thread (on_success callback), so opening windows + # is safe. We then stop and close the wizard. + if self.have_to_sign == "invalidate_classic": + self.thread.stop() + # Design decision (window stacking + user clarity): + # + # When an heir is added to an already-expired will, the rebuilt will + # is itself expired and the old will must be invalidated on-chain + # before the new one can be used. We previously tried to open the + # invalidation transaction window AUTOMATICALLY from here, but doing + # so from within the closing wizard proved fragile: depending on the + # OS window manager and Qt's event ordering, the transaction window + # kept ending up BEHIND the main wallet window (it lost focus when + # the wizard closed). Neither closing-before-opening nor a deferred + # QTimer close() fixed it reliably on every machine. + # + # The robust solution is to NOT auto-open any window here. Instead we + # close the wizard and show a clear instruction telling the user to + # run "Tools -> Invalidate" themselves. That menu path is already + # known to work perfectly (its transaction window always stays in + # front, because no other window is closing at the same time), and + # it also makes the user consciously aware that they are performing a + # deliberate, important action (invalidating their old will). + # + # Close the wizard first so the instruction popup is the only window + # left, then show the guidance message. + self.close() + self.bal_window.show_message( + _( + "Your will has expired and must be invalidated before it " + "can be rebuilt.\n\n" + "Please use the top-right menu Tools -> Invalidate to " + "invalidate your old will: a transaction window will open " + "where you can sign and broadcast the invalidation.\n\n" + "After the invalidation is confirmed, press the Check " + "button near Tools, to finish the will." + ) + ) + return + _logger.debug("have to sign {}".format(self.have_to_sign)) password = None if self.have_to_sign is None: diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 0116637..42510bf 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -470,7 +470,7 @@ class PreviewList(MyTreeView, MessageBoxMixin): # The Wizard is the main entry point to create an inheritance, so make # it stand out: show a bold label next to a slightly larger icon (the # plain icon-only button was too easy to overlook). - wizard = QPushButton(" " + _("Create your will")) + wizard = QPushButton(" " + _("Build Your Will")) wizard.setIcon( read_QIcon_from_bytes( self.bal_window.bal_plugin.read_file("icons/wizard.png") diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 7f5c923..56e0209 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -409,6 +409,14 @@ class Plugin(BalPlugin): # persisted NUM_REMINDERS config (default 3), with a range of 1..5. heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5) + # "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR + # config (default ON, see plugin_base.py), the SAME config used by the + # checkbox inside the "Build your will" wizard's will-executor download + # window, so the two stay in sync automatically. When enabled the plugin + # also builds a will that does not require a will-executor (e.g. it can + # be saved on a USB stick and a copy given to the heirs). + heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR) + # Editable line/text widgets are created once and kept in named # variables so the "Reset" button (Group C / C4b) can refresh the # displayed values after resetting the underlying config. @@ -519,6 +527,21 @@ class Plugin(BalPlugin): ) #add_widget(grid, "Bal Mode", bal_mode, 4, "choose bal mode") + # "No will-executor TX" setting. Mirrors the checkbox shown in the + # wizard's will-executor download window (both bound to NO_WILLEXECUTOR), + # so it can also be toggled from the plugin settings. Default ON. + add_widget( + grid, + "No will-executor TX", + heir_no_willexecutor, + 8, + ( + "Create a will that does not require a Will-executor; it can be " + "saved, for example, on a USB stick, and a copy can be given to " + "the heirs." + ), + ) + # add_widget( # grid, # "Ping Willexecutors", @@ -533,32 +556,25 @@ class Plugin(BalPlugin): # 4, # "Ask before to ping willexecutor", # ) - # add_widget( - # grid, - # "Backup Transaction", - # heir_no_willexecutor, - # 5, - # "Add transactions without willexecutor", - # ) # add_widget(grid,"Enable Multiverse(EXPERIMENTAL/BROKEN)",heir_enable_multiverse,6,"enable multiple locktimes, will import.... ") - grid.addWidget(heir_repush, 8, 0) + grid.addWidget(heir_repush, 9, 0) grid.addWidget( HelpButton( "Broadcast all transactions to willexecutors including those already pushed" ), - 8, + 9, 2, ) # ----------------------------------------------------------------- # # Group C / C4b: "Reset" button that restores the dialog settings to # # their factory defaults. It only resets the settings exposed by THIS # - # dialog (the 6 below) and refreshes the corresponding widgets so the # + # dialog (the ones below) and refreshes the corresponding widgets so # # change is visible immediately. It deliberately does NOT touch the # # wills, will-executors or any other configuration. # # ----------------------------------------------------------------- # def on_reset_defaults(): - """Reset the six dialog settings to their defaults and refresh widgets. + """Reset the dialog settings to their defaults and refresh widgets. The default value of each setting is taken from ``BalConfig.default`` (the third argument used when the config was created in @@ -573,6 +589,7 @@ class Plugin(BalPlugin): (self.AUTO_SIGN, heir_auto_sign, "check"), (self.EDITABLE_DATES, heir_editable_dates, "check"), (self.NUM_REMINDERS, heir_num_reminders, "spin"), + (self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), (self.EVENT_SUMMARY, edit_event_summary, "line"), (self.EVENT_DESCRIPTION, edit_event_description, "text"), ] diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 6b4593a..69c12b6 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -608,7 +608,8 @@ class BalWindow: try: self.check_will() for wid, _w in self.willitems.items(): - self.wallet.set_label(wid, "BAL Transaction") + # Label shown in Electrum's History tab for inheritance txs. + self.wallet.set_label(wid, "BAL Inheritance transaction") rebuilt_ok = True except WillExpiredException as e: self.invalidate_will() @@ -699,7 +700,8 @@ class BalWindow: "Please sign and broadcast this transaction to invalidate current will" ) ) - self.wallet.set_label(result.txid(), "BAL Invalidate") + # Label shown in Electrum's History tab for invalidate txs. + self.wallet.set_label(result.txid(), "BAL Invalidate transaction") self.show_transaction(result) else: self.show_message(_("No transactions to invalidate")) diff --git a/bal/manifest.json b/bal/manifest.json index 1fdfa84..751bac0 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "fullname": "Bitcoin After Life", - "version": "0.3.8", + "version": "0.3.9", "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",