diff --git a/.gitignore b/.gitignore
index 39a207c..68d4ee6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
*.zip
bal-electrum-plugin.zip
electrum-src/
+preview_*.png
diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md
index 6791dc3..f4e545b 100644
--- a/CHANGELOG_REFACTOR.md
+++ b/CHANGELOG_REFACTOR.md
@@ -517,43 +517,95 @@ stato. Le funzioni `server_status_text()` e `server_status_tooltip()` sono in
Confermato dall'utente: **"mi pare che funziona"**.
-## 15. BUGFIX: doppia invalidazione al posticipo dell'eredita (v0.3.1)
+## 15. TENTATIVO E REVERT: fix doppia invalidazione al posticipo (v0.3.1 -> v0.3.2)
-### Sintomo segnalato dall'utente
-Posticipando il delivery time, il plugin chiedeva di firmare **due volte** la
-transazione di invalidazione ("Invalidate your old will"), e solo dopo faceva
-firmare la nuova eredita ("Prepare new will").
+### v0.3.1 (RITIRATA)
+Per risolvere la doppia firma dell'invalidazione al posticipo, era stato
+introdotto `Will.mark_invalidated_by_tx()`, chiamato in
+`loop_broadcast_invalidating` dopo il broadcast dell'invalidazione, per marcare
+`INVALIDATED` le will che spendevano gli stessi UTXO della tx di invalidazione
+e persistere lo stato con `save_willitems`.
-### Causa
-Il percorso di posticipo era:
-1. `task_phase1` rileva il posticipo -> `WillPostponedException` -> costruisce
- la tx di invalidazione -> 1ª firma.
-2. Dopo il broadcast, `on_success_invalidate` **riavvia** `task_phase1` per
- ricostruire la nuova eredita.
-3. **Ma** le will item vecchie erano ancora marcate `COMPLETE`/`PUSHED` con la
- stessa `tx.locktime` di prima (l'invalidazione on-chain non aggiornava lo
- stato in memoria), quindi la condizione del posticipo scattava di **nuovo**
- -> `WillPostponedException` -> **2ª** firma di invalidazione.
-4. Solo al terzo giro la will veniva finalmente ricostruita.
+### Perche e stata ritirata
+La modifica ha introdotto una regressione grave segnalata dall'utente:
+**la lista eredita mostrava ancora le vecchie eredita e l'aggiornamento di
+eredi/date risultava incoerente**.
-### Correzione
-- **`core/will.py`**: nuovo metodo statico `Will.mark_invalidated_by_tx(will,
- tx)` che marca come `INVALIDATED` ogni will valida che spende almeno uno dei
- prevout consumati dalla tx di invalidazione appena trasmessa. Settare
- `INVALIDATED` azzera automaticamente il flag `VALID` (logica gia esistente in
- `WillItem.set_status`), cosi quelle will escono da `only_valid_list`.
-- **`gui/qt/dialogs.py`** (`loop_broadcast_invalidating`): dopo il broadcast
- **riuscito** (quando si ottiene il `txid`), si chiama `mark_invalidated_by_tx`
- e si salva. Al riavvio di `task_phase1` le vecchie will non sono piu `VALID`,
- quindi `WillPostponedException` non viene piu sollevata e la will viene
- ricostruita direttamente. Risultato: **una sola** firma di invalidazione,
- poi la new will.
+Causa: `loop_broadcast_invalidating` e il punto di broadcast usato per **TUTTI**
+i tipi di invalidazione (posticipo, CheckAlive, will scaduto/anticipato), non
+solo per il posticipo. Inoltre il metodo marcava e **persisteva** lo stato
+`INVALIDATED` su tutte le will item che condividevano gli UTXO del wallet
+(tipicamente tutte). Queste will item invalidate restavano poi in memoria e su
+disco, inquinando la ricostruzione di eredi/date e lasciando vecchie voci nella
+lista.
+
+### v0.3.2 (questa versione): REVERT completo
+- Rimosso `Will.mark_invalidated_by_tx()` da `core/will.py`.
+- Rimossa la chiamata in `gui/qt/dialogs.py` (`loop_broadcast_invalidating`):
+ il metodo torna **identico** alla v0.3.0.
+- Rimossi i due test relativi; mantenuto solo l'assert di gerarchia su
+ `WillPostponedException` (corretto e indipendente).
+- `core/will.py` e `gui/qt/dialogs.py` sono ora **byte-identici** alla v0.3.0
+ funzionante (verificato con `git diff a394cde`).
+
+Il bug della doppia invalidazione al posticipo resta quindi **aperto** e andra
+riaffrontato in modo piu mirato (senza toccare il percorso di broadcast comune e
+senza persistere stati su will che condividono gli UTXO), previa conferma
+dell'utente. La priorita era ripristinare il comportamento corretto di
+lista/eredi/date.
+
+## 16. Aggiornamenti mancati, Check/Close coerenti, e rifinitura UI (v0.3.2)
+
+### FIX 1 - Rimozione di un erede rilevata su Check / chiusura Electrum
+`core/will.py` (`check_willexecutors_and_heirs`): prima il plugin
+rilevava solo l'**aggiunta** di un erede (raise `HeirNotFoundException` quando un
+erede corrente non era piu nella will). Mancava il caso inverso: la
+**rimozione** di un erede. Aggiunto il ramo `else` che lancia
+`HeirNotFoundException` anche quando la will porta ancora un erede che non e piu
+presente nel set di eredi corrente. Cosi la ricostruzione dell'eredita scatta su
+**Check** e alla **chiusura di Electrum** (entrambi usano lo stesso percorso
+`BalBuildWillDialog.build_will_task()`), come deciso dall'utente: nessun
+aggiornamento automatico dopo la modifica, solo manuale con Check / alla
+chiusura.
+
+### FIX 2 - Check interroga i server anche per le will gia inviate
+`core/will.py` (nuovo `Will.needs_server_check(w)`) e
+`gui/qt/lists.py` (`PreviewList.check`): prima il Check interrogava i server solo
+per le will in stato `PUSHED`. Le will gia inviate ma rimaste su "New / Not sent"
+non venivano ricontrollate ("nothing to do"). Ora `needs_server_check` include
+ogni will **VALID** con un will-executor e **non ancora CHECKED**, anche se non
+in stato `PUSHED`. Stesso controllo usato sia dal pulsante Check sia da
+`on_close`.
+
+### FIX 3 - Hide invalidated/replaced da finestra Impostazioni aggiornava la lista
+`core/plugin_base.py` (nuovo `sync_hide_filters()`) e
+`gui/qt/window.py` (`update_all`): le checkbox "Hide Replaced" / "Hide
+Invalidated" nella finestra Impostazioni scrivono direttamente la config
+(`BalConfig.set`) senza toccare i flag in cache `_hide_invalidated` /
+`_hide_replaced` usati dalla lista per filtrare. Risultato: la lista continuava
+a filtrare col valore vecchio finche non si riavviava Electrum. Ora
+`update_all()` chiama `sync_hide_filters()` che ri-legge i flag dalla config,
+quindi qualunque sorgente del cambiamento (toolbar o finestra Impostazioni)
+aggiorna subito la lista.
+
+### Rifinitura UI - Risultati in grassetto nel dialog "Building Will"
+`gui/qt/dialogs.py` (`BalBuildWillDialog`): i **risultati** mostrati a destra di
+ogni riga di stato (es. `Ok`, `Ko`, `Nothing to do`, `Skipped`, `Wait`,
+`Timeout`) sono ora resi in **grassetto**, mantenendo i loro colori
+(verde/rosso/giallo). Le etichette di stato a sinistra restano in peso normale.
+Modifica centralizzata negli helper `msg_ok`, `msg_error`, `msg_warning`,
+`msg_set_status`, piu le righe dei will-executor (push e check) che ora mostrano
+`Ok/Ko` e `True/False` in grassetto + colore (verde/rosso).
### Test
-- Aggiunti 2 test in `tests/test_core_will.py`
- (`test_will_mark_invalidated_by_tx`, `test_will_mark_invalidated_by_tx_no_match`)
- e l'assert di gerarchia per `WillPostponedException`.
-- 184 test ufficiali passati; smoke test ed external-zip test OK; `ruff` senza
- nuove segnalazioni.
+- 186 test ufficiali passano; smoke test, external-zip test e simulazione dei
+ flussi di aggiornamento (`tests/sim_update_flows.py`) OK; `ruff` senza nuove
+ segnalazioni reali (solo falsi positivi pre-esistenti da star-import).
+- Aggiunti test in `tests/test_core_will.py`:
+ `test_check_heirs_unchanged_is_coherent`,
+ `test_check_heir_removed_triggers_rebuild`,
+ `test_check_heir_added_triggers_rebuild`, `test_needs_server_check`.
-Confermato dall'utente: **"confermo che funziona"**.
+Confermato dall'utente sui dati reali: dopo Sign -> Broadcast -> Check le
+transazioni gia inviate sono tornate verdi ("confirmed on server"); la lista
+torna pulita; il grassetto e l'aggiornamento delle hide-flag funzionano.
diff --git a/bal/VERSION b/bal/VERSION
index a2268e2..9fc80f9 100644
--- a/bal/VERSION
+++ b/bal/VERSION
@@ -1 +1 @@
-0.3.1
\ No newline at end of file
+0.3.2
\ No newline at end of file
diff --git a/bal/__init__.py b/bal/__init__.py
index 87c2b70..f309239 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.1"
+__version__ = "0.3.2"
diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py
index 1f674bf..8b281db 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.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
diff --git a/bal/core/will.py b/bal/core/will.py
index bdac8e2..4c1f341 100644
--- a/bal/core/will.py
+++ b/bal/core/will.py
@@ -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)
diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py
index c795905..48606ee 100644
--- a/bal/gui/qt/dialogs.py
+++ b/bal/gui/qt/dialogs.py
@@ -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 {} - {} : {}".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 "{}".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 "{}".format(self.COLOR_ERROR, e)
def msg_ok(self, e="Ok"):
- return "{}".format(self.COLOR_OK, e)
+ # Results are shown in bold (see msg_error).
+ return "{}".format(self.COLOR_OK, e)
def msg_warning(self, e):
- return "{}{}".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 ...); wrapping
+ # it again in is harmless for those cases.
status = "Wait" if status is None else status
if color is None:
- line = f"{_(msg)}:\t{status}"
+ line = "{}:\t{}".format(_(msg), status)
else:
- line = "{}:\t{}".format(color, _(msg), status)
+ line = "{}:\t{}".format(
+ _(msg), color, status
+ )
return self.msg_edit_row(line, row)
def ask_password(self, msg=None):
diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py
index 5032a0f..53bd123 100644
--- a/bal/gui/qt/lists.py
+++ b/bal/gui/qt/lists.py
@@ -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)
diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py
index ef2164d..b3950e8 100644
--- a/bal/gui/qt/window.py
+++ b/bal/gui/qt/window.py
@@ -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)
diff --git a/bal/manifest.json b/bal/manifest.json
index 3710ffc..45e609c 100644
--- a/bal/manifest.json
+++ b/bal/manifest.json
@@ -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",
diff --git a/tests/preview_build_will_dialog.py b/tests/preview_build_will_dialog.py
new file mode 100644
index 0000000..20d677a
--- /dev/null
+++ b/tests/preview_build_will_dialog.py
@@ -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 "{}".format(COLOR_OK, e)
+
+
+def error_before(e):
+ return "{}".format(COLOR_ERROR, e)
+
+
+def row_before(msg, status, color=None):
+ if color is None:
+ return f"{msg}:\t{status}"
+ return "{}:\t{}".format(color, msg, status)
+
+
+# ---- proposed rendering (AFTER): results in bold --------------------------
+def ok_after(e="Ok"):
+ return "{}".format(COLOR_OK, e)
+
+
+def error_after(e):
+ return "{}".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{status}"
+ # When a color is given for the whole line, keep the label normal and bold
+ # only the status portion.
+ return "{}:\t{}".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 = "
".join(rows).replace("\n", "
")
+ 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"))
diff --git a/tests/preview_we_rows.py b/tests/preview_we_rows.py
new file mode 100644
index 0000000..91da189
--- /dev/null
+++ b/tests/preview_we_rows.py
@@ -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} : Timeout - no answer"
+ 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 "{}".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 "{}".format(COLOR_ERROR, e)
+
+
+def rows_after():
+ return [
+ # 1. push results: color + bold the Ok / Ko outcome
+ "{} : {}".format(URL1, COLOR_OK, "Ok"),
+ "{} : {}".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 {} - {} : {}".format(URL1, WID, "Waiting"),
+ "checked {} - {} : {}".format(
+ URL1, WID, COLOR_OK, "True"
+ ),
+ "checked {} - {} : {}".format(
+ URL2, WID, COLOR_ERROR, "False"
+ ),
+ ]
+
+
+def render(rows, title, path):
+ full_text = "
".join(rows).replace("\n", "
")
+ 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"))
diff --git a/tests/sim_update_flows.py b/tests/sim_update_flows.py
new file mode 100644
index 0000000..70d324e
--- /dev/null
+++ b/tests/sim_update_flows.py
@@ -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()
diff --git a/tests/test_core_will.py b/tests/test_core_will.py
index 467ebbd..7165e73 100644
--- a/tests/test_core_will.py
+++ b/tests/test_core_will.py
@@ -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,