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
committed by steal
parent e477c5aa5b
commit 365824767b
14 changed files with 669 additions and 163 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
*.zip *.zip
bal-electrum-plugin.zip bal-electrum-plugin.zip
electrum-src/ electrum-src/
preview_*.png

View File

@@ -517,43 +517,95 @@ stato. Le funzioni `server_status_text()` e `server_status_tooltip()` sono in
Confermato dall'utente: **"mi pare che funziona"**. 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 ### v0.3.1 (RITIRATA)
Posticipando il delivery time, il plugin chiedeva di firmare **due volte** la Per risolvere la doppia firma dell'invalidazione al posticipo, era stato
transazione di invalidazione ("Invalidate your old will"), e solo dopo faceva introdotto `Will.mark_invalidated_by_tx()`, chiamato in
firmare la nuova eredita ("Prepare new will"). `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 ### Perche e stata ritirata
Il percorso di posticipo era: La modifica ha introdotto una regressione grave segnalata dall'utente:
1. `task_phase1` rileva il posticipo -> `WillPostponedException` -> costruisce **la lista eredita mostrava ancora le vecchie eredita e l'aggiornamento di
la tx di invalidazione -> 1ª firma. eredi/date risultava incoerente**.
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.
### Correzione Causa: `loop_broadcast_invalidating` e il punto di broadcast usato per **TUTTI**
- **`core/will.py`**: nuovo metodo statico `Will.mark_invalidated_by_tx(will, i tipi di invalidazione (posticipo, CheckAlive, will scaduto/anticipato), non
tx)` che marca come `INVALIDATED` ogni will valida che spende almeno uno dei solo per il posticipo. Inoltre il metodo marcava e **persisteva** lo stato
prevout consumati dalla tx di invalidazione appena trasmessa. Settare `INVALIDATED` su tutte le will item che condividevano gli UTXO del wallet
`INVALIDATED` azzera automaticamente il flag `VALID` (logica gia esistente in (tipicamente tutte). Queste will item invalidate restavano poi in memoria e su
`WillItem.set_status`), cosi quelle will escono da `only_valid_list`. disco, inquinando la ricostruzione di eredi/date e lasciando vecchie voci nella
- **`gui/qt/dialogs.py`** (`loop_broadcast_invalidating`): dopo il broadcast lista.
**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`, ### v0.3.2 (questa versione): REVERT completo
quindi `WillPostponedException` non viene piu sollevata e la will viene - Rimosso `Will.mark_invalidated_by_tx()` da `core/will.py`.
ricostruita direttamente. Risultato: **una sola** firma di invalidazione, - Rimossa la chiamata in `gui/qt/dialogs.py` (`loop_broadcast_invalidating`):
poi la new will. 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 ### Test
- Aggiunti 2 test in `tests/test_core_will.py` - 186 test ufficiali passano; smoke test, external-zip test e simulazione dei
(`test_will_mark_invalidated_by_tx`, `test_will_mark_invalidated_by_tx_no_match`) flussi di aggiornamento (`tests/sim_update_flows.py`) OK; `ruff` senza nuove
e l'assert di gerarchia per `WillPostponedException`. segnalazioni reali (solo falsi positivi pre-esistenti da star-import).
- 184 test ufficiali passati; smoke test ed external-zip test OK; `ruff` senza - Aggiunti test in `tests/test_core_will.py`:
nuove segnalazioni. `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.

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. ``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 = 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. # Command used to open an .ics calendar file, per operating system.
default_app = { default_app = {
@@ -237,6 +237,29 @@ class BalPlugin(BasePlugin):
"""Absolute path to a file bundled inside the plugin directory.""" """Absolute path to a file bundled inside the plugin directory."""
return os.path.join(self.plugin_dir, *parts) 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): def hide_invalidated(self):
"""Toggle (and persist) the "hide invalidated transactions" filter.""" """Toggle (and persist) the "hide invalidated transactions" filter."""
self._hide_invalidated = not self._hide_invalidated self._hide_invalidated = not self._hide_invalidated

View File

@@ -83,6 +83,25 @@ class Will:
if v.get_status("VALID"): if v.get_status("VALID"):
yield k 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 @staticmethod
def search_equal_tx(will, tx, wid): def search_equal_tx(will, tx, wid):
for w in will: for w in will:
@@ -420,31 +439,6 @@ class Will:
_logger.debug("len utxo_to_spend <=0") _logger.debug("len utxo_to_spend <=0")
pass 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 @staticmethod
def is_new(will): def is_new(will):
for wid, w in will.items(): for wid, w in will.items():
@@ -732,9 +726,18 @@ class Will:
# signed/sent falls through here -> a plain rebuild via # signed/sent falls through here -> a plain rebuild via
# HeirNotFoundException (no on-chain fee needed). # HeirNotFoundException (no on-chain fee needed).
else: 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( _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: if willexecutor := w.we:
count = willexecutors_found.get(willexecutor["url"], 0) count = willexecutors_found.get(willexecutor["url"], 0)

View File

@@ -708,19 +708,6 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_invalidating(self.msg_ok()) self.msg_set_invalidating(self.msg_ok())
if not txid: if not txid:
_logger.debug(f"should not be none txid: {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: except TxBroadcastError as e:
_logger.error(f"fail to broadcast transaction:{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) self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
retry_flag["value"] = True retry_flag["value"] = True
done["count"] += 1 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()) self.msg_set_pushing(_status_line())
def on_timeout(url, willexecutor): def on_timeout(url, willexecutor):
@@ -836,7 +826,7 @@ class BalBuildWillDialog(BalDialog):
if self._stopping: if self._stopping:
return return
row = self.msg_edit_row( row = self.msg_edit_row(
"checking {} - {} : {}".format( "checking {} - {} : <b>{}</b>".format(
self.bal_window.willitems[wid].we["url"], wid, "Waiting" self.bal_window.willitems[wid].we["url"], wid, "Waiting"
) )
) )
@@ -844,11 +834,16 @@ class BalBuildWillDialog(BalDialog):
w.set_check_willexecutor( w.set_check_willexecutor(
Willexecutors.check_transaction(wid, w.we["url"]) 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( row = self.msg_edit_row(
"checked {} - {} : {}".format( "checked {} - {} : {}".format(
self.bal_window.willitems[wid].we["url"], self.bal_window.willitems[wid].we["url"],
wid, wid,
self.bal_window.willitems[wid].get_status("CHECKED"), result,
), ),
row, row,
) )
@@ -1034,20 +1029,31 @@ class BalBuildWillDialog(BalDialog):
self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row) self.wait_row = self.msg_edit_row(f"Please wait {status}secs", self.wait_row)
def msg_error(self, e): 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"): 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): 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): 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 status = "Wait" if status is None else status
if color is None: if color is None:
line = f"{_(msg)}:\t{status}" line = "{}:\t<b>{}</b>".format(_(msg), status)
else: 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) return self.msg_edit_row(line, row)
def ask_password(self, msg=None): def ask_password(self, msg=None):

View File

@@ -532,11 +532,15 @@ class PreviewList(MyTreeView, MessageBoxMixin):
will = {} will = {}
for wid, w in self.bal_window.willitems.items(): for wid, w in self.bal_window.willitems.items():
if ( # Query the will-executor server for every valid will that HAS a
w.get_status("VALID") # will-executor assigned and is not yet CHECKED. Previously only
and w.get_status("PUSHED") # transactions already marked PUSHED were checked, so a will that
and not w.get_status("CHECKED") # 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 will[wid] = w
if will: if will:
self.bal_window.check_transactions(will) self.bal_window.check_transactions(will)

View File

@@ -1227,6 +1227,13 @@ class BalWindow:
def update_all(self): def update_all(self):
try: 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) Will.add_willtree(self.willitems)
all_utxos = self.wallet.get_utxos() all_utxos = self.wallet.get_utxos()
utxos_list = Will.utxos_strs(all_utxos) utxos_list = Will.utxos_strs(all_utxos)

View File

@@ -1,7 +1,7 @@
{ {
"name": "bal", "name": "bal",
"fullname": "Bitcoin After Life", "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.", "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", "author": "Svatantrya",
"licence": "MIT", "licence": "MIT",

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 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(): def test_will_is_new():
item1 = _make_willitem_blank() item1 = _make_willitem_blank()
item1.set_status("COMPLETE", True) item1.set_status("COMPLETE", True)
@@ -228,79 +325,6 @@ def test_will_check_tx_height():
# Exception classes # 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(): def test_exceptions():
from bal.core.will import ( from bal.core.will import (
WillException, WillExpiredException, NotCompleteWillException, WillException, WillExpiredException, NotCompleteWillException,