fix(plugin): missed-update fixes, server re-check, and bold Building Will results (v0.3.2)

Revert the v0.3.1 double-invalidation change (it caused an inheritance-list
regression: stale/invalidated wills lingered and heir/date updates became
incoherent) and add several targeted missed-update fixes plus a UI refinement.

Revert (v0.3.1 -> v0.3.2):
- Remove Will.mark_invalidated_by_tx() and its call in
  loop_broadcast_invalidating. core/will.py and gui/qt/dialogs.py are restored
  to the working v0.3.0 behaviour. The postpone double-invalidation issue is
  intentionally left open, to be addressed without touching the shared
  broadcast path.

FIX 1 - detect heir removal on Check / Electrum close:
- core/will.py (check_willexecutors_and_heirs): the else-branch now raises
  HeirNotFoundException when a will still carries an heir that is no longer in
  the current heirs set (heir removed), mirroring the existing 'heir added'
  path. Rebuild therefore triggers on Check and on_close (same build_will_task
  path), as decided by the user (manual update only, no auto-rebuild).

FIX 2 - Check queries servers for already-sent wills:
- core/will.py: new Will.needs_server_check(w) returns True for any VALID will
  with a will-executor that is not yet CHECKED (no longer limited to PUSHED).
- gui/qt/lists.py (PreviewList.check): use needs_server_check so wills stuck on
  'New / Not sent' are re-checked instead of reporting 'nothing to do'.

FIX 3 - Settings-dialog hide toggles refresh the list:
- core/plugin_base.py: new sync_hide_filters() re-reads the cached
  _hide_invalidated / _hide_replaced flags from the persisted config.
- gui/qt/window.py (update_all): call sync_hide_filters() before refreshing, so
  toggling 'Hide Invalidated' / 'Hide Replaced' in the Settings dialog (which
  writes the config directly) updates the transaction list immediately instead
  of requiring an Electrum restart.

UI - bold results in the Building Will dialog:
- gui/qt/dialogs.py (BalBuildWillDialog): render the right-side results in bold
  (Ok, Ko, Nothing to do, Skipped, Wait, Timeout, ...) keeping the left-side
  state labels in normal weight. Centralised in msg_ok/msg_error/msg_warning/
  msg_set_status, plus the will-executor push/check rows now show Ok/Ko and
  True/False in bold + colour (green/red).

Tests/tooling:
- tests/test_core_will.py: add test_check_heirs_unchanged_is_coherent,
  test_check_heir_removed_triggers_rebuild, test_check_heir_added_triggers_rebuild,
  test_needs_server_check.
- tests/sim_update_flows.py: real-world update-scenario simulation.
- tests/preview_build_will_dialog.py, tests/preview_we_rows.py: GUI-only
  before/after previews of the bold formatting.
- Bump version to 0.3.2 (VERSION, manifest.json, __init__.py, plugin_base.py).

186 tests pass; smoke test, external-zip test and update-flow simulation OK;
ruff reports only pre-existing star-import false positives.
This commit is contained in:
GenSpark AI Developer
2026-06-16 07:56:11 +00:00
parent d0947f7e50
commit a1c6710d87
14 changed files with 669 additions and 163 deletions

View File

@@ -1 +1 @@
0.3.1
0.3.2

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.3.1"
__version__ = "0.3.2"

View File

@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
"""
_version = None
__version__ = "0.3.1" # AUTOMATICALLY GENERATED DO NOT EDIT
__version__ = "0.3.2" # AUTOMATICALLY GENERATED DO NOT EDIT
# Command used to open an .ics calendar file, per operating system.
default_app = {
@@ -237,6 +237,29 @@ class BalPlugin(BasePlugin):
"""Absolute path to a file bundled inside the plugin directory."""
return os.path.join(self.plugin_dir, *parts)
def sync_hide_filters(self):
"""Re-read the "hide" filter flags from the persisted config.
The cached ``_hide_invalidated`` / ``_hide_replaced`` flags are used by
the GUI list to decide which rows to skip. They can be changed from two
different places:
* the list toolbar buttons, which call :meth:`hide_invalidated` /
:meth:`hide_replaced` (a toggle that updates both the cache and the
config), and
* the Settings dialog checkboxes, which write the config directly
(``BalConfig.set``) without touching the cached flags.
In the second case the cache and the config would drift apart and the
transaction list would keep filtering with the *old* value, so the
toggled rows never appear/disappear until Electrum is restarted.
Re-syncing the cache from the config here (called by ``update_all``)
keeps every code path coherent regardless of where the change came
from.
"""
self._hide_invalidated = self.HIDE_INVALIDATED.get()
self._hide_replaced = self.HIDE_REPLACED.get()
def hide_invalidated(self):
"""Toggle (and persist) the "hide invalidated transactions" filter."""
self._hide_invalidated = not self._hide_invalidated

View File

@@ -83,6 +83,25 @@ class Will:
if v.get_status("VALID"):
yield k
@staticmethod
def needs_server_check(w):
"""Return True if ``w`` should be queried on its will-executor server
when the user presses Check (or on Electrum close).
A will needs a server check when it is VALID, has a will-executor
assigned, and is not yet CHECKED. This intentionally includes wills
that are not (yet) marked PUSHED: a will that was actually sent in the
past but whose saved status still reads "New" would otherwise be
skipped, leaving the Server column stuck on "Not sent". The server
response (see WillItem.set_check_willexecutor) then corrects the status
to PUSHED/CHECKED if the transaction is present, or CHECK_FAIL if not.
"""
return bool(
w.get_status("VALID")
and w.we
and not w.get_status("CHECKED")
)
@staticmethod
def search_equal_tx(will, tx, wid):
for w in will:
@@ -420,31 +439,6 @@ class Will:
_logger.debug("len utxo_to_spend <=0")
pass
@staticmethod
def mark_invalidated_by_tx(will, tx):
"""Mark as INVALIDATED every valid will item that spends at least one
of the prevouts consumed by ``tx`` (the on-chain invalidation tx that
was just broadcast).
Once the invalidation tx is broadcast, the previously signed/sent will
transactions that relied on those same UTXOs can no longer be mined, so
their will items must stop being VALID. Setting INVALIDATED clears the
VALID flag (see WillItem.set_status), which removes them from
only_valid_list and therefore prevents the postpone/expire check from
firing a *second* invalidation on the next pass.
Returns the list of will ids that were marked.
"""
spent_prevouts = {i.prevout.to_str() for i in tx.inputs()}
invalidated = []
for wid in Will.only_valid_list(will):
w = will[wid]
wi_prevouts = {i.prevout.to_str() for i in w.tx.inputs()}
if spent_prevouts & wi_prevouts:
Will.set_invalidate(wid, will)
invalidated.append(wid)
return invalidated
@staticmethod
def is_new(will):
for wid, w in will.items():
@@ -732,9 +726,18 @@ class Will:
# signed/sent falls through here -> a plain rebuild via
# HeirNotFoundException (no on-chain fee needed).
else:
# The will still carries this heir, but the heir is no
# longer present in the current heirs set: the user
# removed it. This must trigger a rebuild exactly like
# "heir added" does, otherwise the removed heir would
# silently stay in the inheritance transaction. Raising
# HeirNotFoundException reuses the same rebuild path used
# by the Check button and by on_close (Electrum quit).
_logger.debug(
f"heir not present transaction is not valid:{wheir} {wid}, {w}"
f"heir removed, transaction is not valid:"
f"{wheir} {wid}, {w}"
)
raise HeirNotFoundException(wheir)
if willexecutor := w.we:
count = willexecutors_found.get(willexecutor["url"], 0)

View File

@@ -708,19 +708,6 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_invalidating(self.msg_ok())
if not txid:
_logger.debug(f"should not be none txid: {txid}")
else:
# The invalidation tx is now broadcast, so the old signed/sent
# will transactions spending those same UTXOs can no longer be
# mined. Mark them INVALIDATED (which clears their VALID flag)
# so the postpone/expire check does NOT fire a second
# invalidation when phase 1 is restarted to rebuild the new
# (postponed) will.
invalidated = Will.mark_invalidated_by_tx(
self.bal_window.willitems, tx
)
if invalidated:
_logger.debug(f"invalidated will items: {invalidated}")
self.bal_window.save_willitems()
except TxBroadcastError as e:
_logger.error(f"fail to broadcast transaction:{e}")
@@ -784,7 +771,10 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
retry_flag["value"] = True
done["count"] += 1
self.msg_edit_row("{} : {}".format(url, "Ok" if ok else "Ko"))
# Show the per-server result (Ok/Ko) in bold + color so the
# outcome stands out, keeping the server URL in normal weight.
result = self.msg_ok("Ok") if ok else self.msg_error("Ko")
self.msg_edit_row("{} : {}".format(url, result))
self.msg_set_pushing(_status_line())
def on_timeout(url, willexecutor):
@@ -836,7 +826,7 @@ class BalBuildWillDialog(BalDialog):
if self._stopping:
return
row = self.msg_edit_row(
"checking {} - {} : {}".format(
"checking {} - {} : <b>{}</b>".format(
self.bal_window.willitems[wid].we["url"], wid, "Waiting"
)
)
@@ -844,11 +834,16 @@ class BalBuildWillDialog(BalDialog):
w.set_check_willexecutor(
Willexecutors.check_transaction(wid, w.we["url"])
)
# Show the CHECKED result in bold + color (green True /
# red False) so the outcome stands out, keeping the server
# URL and tx id in normal weight.
checked = self.bal_window.willitems[wid].get_status("CHECKED")
result = self.msg_ok(checked) if checked else self.msg_error(checked)
row = self.msg_edit_row(
"checked {} - {} : {}".format(
self.bal_window.willitems[wid].we["url"],
wid,
self.bal_window.willitems[wid].get_status("CHECKED"),
result,
),
row,
)
@@ -1034,20 +1029,31 @@ class BalBuildWillDialog(BalDialog):
self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row)
def msg_error(self, e):
return "<font color='{}'>{}</font>".format(self.COLOR_ERROR, e)
# Results are shown in bold so the outcome stands out from the
# left-side state label (which stays in normal weight).
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
def msg_ok(self, e="Ok"):
return "<font color='{}'>{}</font>".format(self.COLOR_OK, e)
# Results are shown in bold (see msg_error).
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_OK, e)
def msg_warning(self, e):
return "<font color='{}'>{}</font".format(self.COLOR_WARNING, e)
# Results are shown in bold (see msg_error).
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_WARNING, e)
def msg_set_status(self, msg, row=None, status=None, color=None):
# The left "state" label keeps its normal weight; only the right-side
# result (``status``) is rendered in bold so it is easy to read at a
# glance. ``status`` may already contain rich-text emitted by
# msg_ok/msg_error/msg_warning (which add their own <b>...</b>); wrapping
# it again in <b> is harmless for those cases.
status = "Wait" if status is None else status
if color is None:
line = f"{_(msg)}:\t{status}"
line = "{}:\t<b>{}</b>".format(_(msg), status)
else:
line = "<font color={}>{}:\t{}</font>".format(color, _(msg), status)
line = "{}:\t<font color={}><b>{}</b></font>".format(
_(msg), color, status
)
return self.msg_edit_row(line, row)
def ask_password(self, msg=None):

View File

@@ -532,11 +532,15 @@ class PreviewList(MyTreeView, MessageBoxMixin):
will = {}
for wid, w in self.bal_window.willitems.items():
if (
w.get_status("VALID")
and w.get_status("PUSHED")
and not w.get_status("CHECKED")
):
# Query the will-executor server for every valid will that HAS a
# will-executor assigned and is not yet CHECKED. Previously only
# transactions already marked PUSHED were checked, so a will that
# had actually been sent in the past but whose saved status still
# read "New" (not PUSHED) was skipped and the Check button reported
# "nothing to do". Will.needs_server_check now also includes such
# non-PUSHED wills, so the server can confirm the transaction is
# present and correct the status (see set_check_willexecutor).
if Will.needs_server_check(w):
will[wid] = w
if will:
self.bal_window.check_transactions(will)

View File

@@ -1227,6 +1227,13 @@ class BalWindow:
def update_all(self):
try:
# Re-sync the cached "hide invalidated/replaced" flags from the
# persisted config before refreshing the list. The Settings dialog
# checkboxes write the config directly (without touching the cached
# flags), so without this the list would keep filtering with the old
# value and the invalidated/replaced rows would not appear/disappear
# until Electrum was restarted.
self.bal_plugin.sync_hide_filters()
Will.add_willtree(self.willitems)
all_utxos = self.wallet.get_utxos()
utxos_list = Will.utxos_strs(all_utxos)

View File

@@ -1,7 +1,7 @@
{
"name": "bal",
"fullname": "Bitcoin After Life",
"version": "0.3.1",
"version": "0.3.2",
"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",