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

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Render a visual PREVIEW (before/after) of the "Building Will" dialog text.
This is a throwaway, GUI-only helper used to show the user how the proposed
"bold results" formatting looks compared to the current rendering, BEFORE any
production code is changed. It does NOT import the plugin; it just reproduces
the exact rich-text the dialog builds via ``msg_set_status`` / ``msg_ok`` /
``msg_error`` so the preview is faithful.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_build_will_dialog.py
It writes two PNGs in the repo root: preview_before.png and preview_after.png.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
# Same colors as BalBuildWillDialog
COLOR_WARNING = "#cfa808"
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
# ---- current rendering (BEFORE) -------------------------------------------
def ok_before(e="Ok"):
return "<font color='{}'>{}</font>".format(COLOR_OK, e)
def error_before(e):
return "<font color='{}'>{}</font>".format(COLOR_ERROR, e)
def row_before(msg, status, color=None):
if color is None:
return f"{msg}:\t{status}"
return "<font color={}>{}:\t{}</font>".format(color, msg, status)
# ---- proposed rendering (AFTER): results in bold --------------------------
def ok_after(e="Ok"):
return "<font color='{}'><b>{}</b></font>".format(COLOR_OK, e)
def error_after(e):
return "<font color='{}'><b>{}</b></font>".format(COLOR_ERROR, e)
def row_after(msg, status, color=None):
# Left state label stays normal; only the result (status) becomes bold.
if color is None:
return f"{msg}:\t<b>{status}</b>"
# When a color is given for the whole line, keep the label normal and bold
# only the status portion.
return "{}:\t<font color={}><b>{}</b></font>".format(msg, color, status)
def build_rows(mode):
if mode == "before":
ok, err, row = ok_before, error_before, row_before
else:
ok, err, row = ok_after, error_after, row_after
rows = [
row("checking variables", "Wait"),
row("Checking your will", ok()),
row("Signing your will", "Nothing to do"),
row("Broadcasting your will to executors", "Nothing to do"),
ok(),
row("Invalidating old will", err("Ko")),
"https://executor.example.org : " + ok(),
"https://other.example.org : " + err("Ko"),
"Please wait 2secs",
row("Will-Executor excluded", "Skipped", COLOR_ERROR),
]
return rows
def render(mode, path):
rows = build_rows(mode)
full_text = "<br><br>".join(rows).replace("\n", "<br>")
w = QWidget()
w.setStyleSheet("background:#2b2b2b;")
lay = QVBoxLayout(w)
title = QLabel(f"Building Will — {mode.upper()}")
title.setStyleSheet("color:#ffffff; font-size:15px; font-weight:bold;")
lbl = QLabel(full_text)
lbl.setTextFormat(Qt.TextFormat.RichText)
lbl.setStyleSheet("color:#dddddd; font-size:13px;")
lbl_font = lbl.font()
lbl_font.setPointSize(11)
lbl.setFont(lbl_font)
lay.addWidget(title)
lay.addWidget(lbl)
w.resize(560, 420)
w.show()
app.processEvents()
pix = w.grab()
pix.save(path)
print(f"[{mode}] saved -> {path}")
if __name__ == "__main__":
app = QApplication(sys.argv)
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
render("before", os.path.join(here, "preview_before.png"))
render("after", os.path.join(here, "preview_after.png"))

105
tests/preview_we_rows.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Visual PREVIEW focused on the WILL-EXECUTOR rows of the Building Will dialog.
Reproduces faithfully the three real variants built in dialogs.py:
1. Broadcasting (push) result -> line 774: "{url} : {Ok|Ko}" (plain, no color today)
2. Timeout -> line 783: "{url} : <font red>Timeout - no answer</font>"
3. Checking already-present -> line 825/834:
"checking {url} - {wid} : Waiting"
"checked {url} - {wid} : True/False" (plain, no color today)
Shows BEFORE (current) vs AFTER (proposed: result in bold, keeping label as-is).
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_we_rows.py
Writes preview_we_before.png / preview_we_after.png in the repo root.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
URL1 = "https://executor.example.org"
URL2 = "https://other-executor.net"
WID = "a1b2c3"
def err(e):
return "<font color='{}'>{}</font>".format(COLOR_ERROR, e)
# ---------------- BEFORE: exactly as the code builds today -----------------
def rows_before():
return [
# 1. push results (plain text, no color/bold today)
"{} : {}".format(URL1, "Ok"),
"{} : {}".format(URL2, "Ko"),
# 2. timeout (already red, not bold)
"{} : {}".format(URL1, err("Timeout - no answer")),
# 3. already-present check
"checking {} - {} : {}".format(URL1, WID, "Waiting"),
"checked {} - {} : {}".format(URL1, WID, "True"),
"checked {} - {} : {}".format(URL2, WID, "False"),
]
# ---------------- AFTER: result portion in bold, label unchanged -----------
def err_after(e):
return "<font color='{}'><b>{}</b></font>".format(COLOR_ERROR, e)
def rows_after():
return [
# 1. push results: color + bold the Ok / Ko outcome
"{} : <font color='{}'><b>{}</b></font>".format(URL1, COLOR_OK, "Ok"),
"{} : <font color='{}'><b>{}</b></font>".format(URL2, COLOR_ERROR, "Ko"),
# 2. timeout: bold the red message
"{} : {}".format(URL1, err_after("Timeout - no answer")),
# 3. already-present check: bold the result
"checking {} - {} : <b>{}</b>".format(URL1, WID, "Waiting"),
"checked {} - {} : <font color='{}'><b>{}</b></font>".format(
URL1, WID, COLOR_OK, "True"
),
"checked {} - {} : <font color='{}'><b>{}</b></font>".format(
URL2, WID, COLOR_ERROR, "False"
),
]
def render(rows, title, path):
full_text = "<br><br>".join(rows).replace("\n", "<br>")
w = QWidget()
w.setStyleSheet("background:#2b2b2b;")
lay = QVBoxLayout(w)
t = QLabel(title)
t.setStyleSheet("color:#ffffff; font-size:15px; font-weight:bold;")
lbl = QLabel(full_text)
lbl.setTextFormat(Qt.TextFormat.RichText)
lbl.setStyleSheet("color:#dddddd;")
f = lbl.font()
f.setPointSize(11)
lbl.setFont(f)
lay.addWidget(t)
lay.addWidget(lbl)
w.resize(560, 320)
w.show()
app.processEvents()
w.grab().save(path)
print(f"saved -> {path}")
if __name__ == "__main__":
app = QApplication(sys.argv)
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
render(rows_before(), "Will-Executor rows — BEFORE",
os.path.join(here, "preview_we_before.png"))
render(rows_after(), "Will-Executor rows — AFTER",
os.path.join(here, "preview_we_after.png"))

171
tests/sim_update_flows.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Real-world simulation of the inheritance update flows.
This script does NOT touch the GUI. It drives the core decision function
``Will.check_willexecutors_and_heirs`` (the one that decides whether a will is
still coherent or must be rebuilt) through the scenarios the user reported:
1. delivery date moved forward (postpone) -> must NOT stay "coherent"
2. an heir is added -> must trigger rebuild
3. an heir is removed -> must trigger rebuild
4. a single heir percentage / amount is changed -> must trigger rebuild
5. nothing changed -> stays coherent
For each scenario we report which exception (if any) is raised, because that is
exactly what the GUI relies on to decide whether to rebuild the inheritance
transactions. If the function returns True (coherent) when something DID
change, the GUI will (correctly) show no update -- which is the symptom the
user described.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
"""
import sys
import os
import copy
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import (
WillItem, Will,
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
TxFeesChangedException, WillExpiredException,
)
from bal.core.util import Util
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# A locktime far in the past (so the frozen tx.locktime is a fixed integer we
# control via monkey-patching below). We will override w.tx.locktime per test.
TX_FEES = 100
def _make_will_item(heirs, tx_locktime, status_complete=False):
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
# Force the locktime frozen "inside" the signed tx.
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
return item
def _run(label, will_heirs, current_heirs, tx_locktime,
status_complete=False, check_date=0):
"""Run check_willexecutors_and_heirs and report the outcome."""
item = _make_will_item(will_heirs, tx_locktime, status_complete)
will = {"willid_1": item}
outcome = None
try:
result = Will.check_willexecutors_and_heirs(
will,
current_heirs, # the (possibly edited) heirs dict
{}, # willexecutors
False, # self_willexecutor
check_date, # check_date (timestamp)
TX_FEES, # tx_fees
)
outcome = f"coherent (returned {result})"
except HeirNotFoundException as e:
outcome = f"HeirNotFoundException: {e}"
except NoHeirsException as e:
outcome = f"NoHeirsException: {e}"
except TxFeesChangedException as e:
outcome = f"TxFeesChangedException: {e}"
except WillExpiredException as e:
outcome = f"WillExpiredException: {e}"
except NotCompleteWillException as e:
outcome = f"{type(e).__name__}: {e}"
except Exception as e:
outcome = f"!! UNEXPECTED {type(e).__name__}: {e}"
print(f"[{label}]")
print(f" -> {outcome}")
return outcome
def main():
# locktime string "0d" -> Util.parse_locktime_string returns a timestamp
# ~ now. We use explicit integer timestamps to keep things deterministic.
base_lt = 1900000000 # frozen tx.locktime (year ~2030)
later_lt = "2000000000" # a later locktime string (postpone)
same_lt = str(base_lt)
# Scenario 0: nothing changed -> should be coherent.
heirs = {"alice": ["addr_alice", 5000, same_lt]}
_run("0. nothing changed",
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
tx_locktime=base_lt, check_date=0)
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {"alice": ["addr_alice", 5000, later_lt]}
_run("1. date postponed (unsigned will)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 1b: postpone on a SIGNED will (status COMPLETE).
_run("1b. date postponed (SIGNED will)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, status_complete=True, check_date=0)
# Scenario 2: an heir is ADDED.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {
"alice": ["addr_alice", 5000, same_lt],
"bob": ["addr_bob", 3000, same_lt],
}
_run("2. heir added (bob)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 3: an heir is REMOVED.
heirs_will = {
"alice": ["addr_alice", 5000, same_lt],
"bob": ["addr_bob", 3000, same_lt],
}
heirs_now = {"alice": ["addr_alice", 5000, same_lt]}
_run("3. heir removed (bob)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 4: a single heir AMOUNT/percentage changed.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {"alice": ["addr_alice", 9999, same_lt]}
_run("4. heir amount changed (5000 -> 9999)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 5: heir ADDRESS changed.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {"alice": ["addr_NEW", 5000, same_lt]}
_run("5. heir address changed",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
print("\n[done] simulation finished")
if __name__ == "__main__":
main()

View File

@@ -164,6 +164,103 @@ def test_will_only_valid_list():
assert "b" not in result
def _make_will_with_heirs(heirs, tx_locktime):
"""Build a single-item will whose stored heirs == ``heirs`` and whose
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
return {"willid_1": item}
def test_check_heirs_unchanged_is_coherent():
"""No heir change -> the will stays coherent (no rebuild)."""
lt = 1900000000
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(heirs, lt)
result = Will.check_willexecutors_and_heirs(
will, copy.deepcopy(heirs), {}, False, 0, 100
)
assert result is True
def test_check_heir_removed_triggers_rebuild():
"""Removing an heir MUST be detected (HeirNotFoundException), so the Check
button and on_close rebuild the inheritance. Regression test for the bug
where a removed heir silently stayed in the transaction."""
from bal.core.will import HeirNotFoundException
lt = 1900000000
will_heirs = {
"alice": ["addr_alice", 5000, str(lt)],
"bob": ["addr_bob", 3000, str(lt)],
}
current_heirs = {"alice": ["addr_alice", 5000, str(lt)]} # bob removed
will = _make_will_with_heirs(will_heirs, lt)
raised = False
try:
Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 100
)
except HeirNotFoundException:
raised = True
assert raised, "removing an heir must raise HeirNotFoundException"
def test_check_heir_added_triggers_rebuild():
"""Adding an heir must be detected (HeirNotFoundException)."""
from bal.core.will import HeirNotFoundException
lt = 1900000000
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
current_heirs = {
"alice": ["addr_alice", 5000, str(lt)],
"bob": ["addr_bob", 3000, str(lt)], # added
}
will = _make_will_with_heirs(will_heirs, lt)
raised = False
try:
Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 100
)
except HeirNotFoundException:
raised = True
assert raised, "adding an heir must raise HeirNotFoundException"
def test_needs_server_check():
"""Check button selection logic: a VALID will with a will-executor that is
not yet CHECKED must be queried on the server, even if it is not PUSHED
(regression for the 'New / Not sent' wills that Check ignored)."""
we = {"url": "https://we.example.com"}
# New (not PUSHED) but has a will-executor -> must be checked.
item_new = _make_willitem_blank()
item_new.we = we
assert Will.needs_server_check(item_new) is True
# PUSHED but not CHECKED -> must be checked (previous behaviour).
item_pushed = _make_willitem_blank()
item_pushed.we = we
item_pushed.set_status("PUSHED", True)
assert Will.needs_server_check(item_pushed) is True
# Already CHECKED -> no need to check again.
item_checked = _make_willitem_blank()
item_checked.we = we
item_checked.set_status("CHECKED", True)
assert Will.needs_server_check(item_checked) is False
# No will-executor assigned -> nothing to check on a server.
item_no_we = _make_willitem_blank()
item_no_we.we = None
assert Will.needs_server_check(item_no_we) is False
# Not VALID (e.g. invalidated) -> not checked.
item_invalid = _make_willitem_blank()
item_invalid.we = we
item_invalid.set_status("INVALIDATED", True)
assert Will.needs_server_check(item_invalid) is False
def test_will_is_new():
item1 = _make_willitem_blank()
item1.set_status("COMPLETE", True)
@@ -228,79 +325,6 @@ def test_will_check_tx_height():
# Exception classes
# ------------------------------------------------------------------ #
def test_will_mark_invalidated_by_tx():
"""A valid will spending the same prevout as the invalidation tx must be
marked INVALIDATED (and therefore lose its VALID flag). This is what
prevents the postpone/expire check from firing a *second* invalidation
when phase 1 is restarted after a successful on-chain invalidation."""
class FakePrevout:
def __init__(self, s):
self._s = s
def to_str(self):
return self._s
class FakeInput:
def __init__(self, s):
self.prevout = FakePrevout(s)
class FakeTx:
def __init__(self, prevouts):
self._inputs = [FakeInput(p) for p in prevouts]
def inputs(self):
return self._inputs
# The real test will item spends this prevout (from _VALID_TX_HEX).
spent = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# Will item that spends the same UTXO -> must be invalidated.
item_match = _make_willitem_blank()
item_match.set_status("COMPLETE", True)
# Will item that spends an unrelated UTXO -> must stay VALID.
item_other = _make_willitem_blank()
item_other.tx = FakeTx(["deadbeef:1"])
item_other.children = {}
will = {"match": item_match, "other": item_other}
inval_tx = FakeTx([spent])
invalidated = Will.mark_invalidated_by_tx(will, inval_tx)
assert "match" in invalidated
assert "other" not in invalidated
assert will["match"].get_status("INVALIDATED") is True
assert will["match"].get_status("VALID") is False
assert will["other"].get_status("VALID") is True
def test_will_mark_invalidated_by_tx_no_match():
"""If no valid will spends any of the invalidation tx's prevouts, nothing
is marked."""
class FakePrevout:
def __init__(self, s):
self._s = s
def to_str(self):
return self._s
class FakeInput:
def __init__(self, s):
self.prevout = FakePrevout(s)
class FakeTx:
def __init__(self, prevouts):
self._inputs = [FakeInput(p) for p in prevouts]
def inputs(self):
return self._inputs
item = _make_willitem_blank()
will = {"a": item}
inval_tx = FakeTx(["unrelated:9"])
invalidated = Will.mark_invalidated_by_tx(will, inval_tx)
assert invalidated == []
assert will["a"].get_status("VALID") is True
def test_exceptions():
from bal.core.will import (
WillException, WillExpiredException, NotCompleteWillException,