feat(v0.4.7): report area 500px, heirs one-per-line, wizard line breaks, ALL-DUST guard

Owner-approved changes after testing v0.4.6, plus accumulated v0.4.x work,
task-tracking notes, and an updated project HANDOFF document.

The four v0.4.7 changes:

1. Report area (BalBuildWillDialog) opens 500px tall (min) up to 700px (max),
   then the scrollbar takes over. Previously it opened ~140px (too short).

2. Heirs are listed ONE per line again (green, bold) in _build_success_report,
   reverting the v0.4.6 single-line form. Heir names can be long and the report
   now scrolls, so compression is no longer needed.

3. Two wizard texts get an explicit line break: after "(or backup)" in the date
   hint and after "miner fees" in the fee note (widgets.py).

4. ALL-DUST guard: when EVERY heir's share is below the Bitcoin dust limit, the
   inheritance would pay nobody. Heirs.prepare_lists now raises
   HeirAmountIsDustException at the end (where all heirs across all locktimes
   are known with their final dust state), and dialogs.task_phase1 shows a clear
   RED message and stops without building/signing/checking. A mix of dust +
   valid heirs keeps building normally. The guard is intentionally in
   prepare_lists, NOT prepare_transactions (which only sees the lowest locktime
   and would false-positive). HeirAmountIsDustException is imported in common.py.

Tests: 3 new tests in test_core_heirs_extra.py pin the dust behaviour (all-dust
raises; mixed continues; multi-locktime continues). Full suite: 258 passed.
ruff: no new errors. Version bumped 0.4.6 -> 0.4.7 (4 files). CHANGELOG #23.

Also adds/updates HANDOFF.md so any future AI (Claude or another model) can
resume the project with full context (rules, layout, build/test/lint, dust
logic, git flow), and records the task-tracking notes in
.agent_memory_tasks.md.
This commit is contained in:
2026-06-28 23:02:25 -04:00
parent ed83af6be9
commit 646a33f2f5
19 changed files with 2803 additions and 323 deletions

829
.agent_memory_tasks.md Normal file
View File

@@ -0,0 +1,829 @@
## TASK A (proposto, NON ancora avviato) — Migliorare il messaggio "WILL EXPIRED"
**Origine:** osservato dall'utente negli screenshot del 23/06/2026 (will scaduto -> percorso invalidate + re-sign). La LOGICA è corretta; si migliora solo la UX/chiarezza del messaggio.
**3 miglioramenti approvati dall'utente (da implementare poi, in inglese, regole R1-R4 + zip-first):**
1. Tradurre il timestamp Unix grezzo (es. 1782118800) in data leggibile.
Esempio: invece di "Will Expired 9f1b0a75...: 1782118800"
mostrare "Will expired (locktime 2026-06-22 11:00 UTC) - too late to anticipate, will invalidate and re-sign".
2. Rendere il messaggio INFORMATIVO e non un ERRORE: il rosso sembra un errore,
ma e' un flusso normale. Usare colore di avviso (arancione) o aggiungere frase
tipo "This is expected: the will is past its locktime, switching to invalidate + re-sign."
3. Accorciare l'hash del will per leggibilita' (es. primi 8 + ultimi 4 caratteri).
**Note tecniche da fare in DISCOVER quando si avvia:**
- Trovare il punto del codice che genera la stringa "Will Expired ... <timestamp>" (probabilmente nel wizard "Building Will").
- Verificare se il colore rosso e' impostato li' (rich text / stylesheet).
- Seguire METHOD: DISCOVER -> PLAN (attendere OK) -> EXECUTE -> VERIFY -> ZIP-first -> commit/PR/release solo dopo conferma.
## TASK B (proposto, NON ancora avviato) — Etichette descrizione transazioni in Cronologia
**Origine:** osservato dall'utente nello screenshot Cronologia del 23/06/2026.
Nella tab "Cronologia" di Electrum (transazioni on-chain), il plugin BAL scrive
una descrizione colorata nella colonna "Descrizione".
**Stato attuale:**
- La transazione di EREDITA' e' etichettata "BAL Transaction" (colore rosso).
- La transazione di INVALIDATE NON ha alcuna descrizione.
**Modifiche richieste (approvate dall'utente):**
1. RINOMINARE l'etichetta dell'eredita': "BAL Transaction" -> "BAL Inheritance transaction".
2. CAMBIARE il colore dell'etichetta eredita' da ROSSO a VERDE.
3. AGGIUNGERE una nuova etichetta per le transazioni di invalidate:
"BAL Invalidate transaction" in colore ARANCIONE (oggi compaiono senza descrizione).
**Riepilogo finale desiderato:**
| Transazione | Etichetta desiderata | Colore |
|-------------|-----------------------------|-----------|
| Eredita' | BAL Inheritance transaction | VERDE |
| Invalidate | BAL Invalidate transaction | ARANCIONE |
**Note tecniche da fare in DISCOVER quando si avvia:**
- Trovare nel codice dove viene impostata l'etichetta "BAL Transaction"
(probabilmente wallet.set_label(txid, ...) o simile) e dove/come e' impostato il colore.
- Capire come il plugin distingue una tx di eredita' da una di invalidate, per
applicare l'etichetta corretta a ciascuna. La tx di invalidate oggi NON riceve
label -> trovare il punto dove viene creata/broadcastata e aggiungere lì set_label.
- Verificare se il colore (rosso) e' gestito da Electrum o dal plugin, e come
impostare il verde per l'eredita'.
- Seguire METHOD: DISCOVER -> PLAN (attendere OK) -> EXECUTE -> VERIFY -> ZIP-first -> commit/PR/release solo dopo conferma.
## TASK C (proposto, NON ancora avviato) — Checkbox "no will-executor" anche in Plugin Settings
**Origine:** richiesta utore del 23/06/2026.
Nel wizard "Create your WILL", finestra di download will-executor, esiste il checkbox
"Add transactions without willexecutor". L'utente vuole lo STESSO checkbox anche nella
finestra "Plugin settings" (stesso stile delle altre righe), default ON, con un
HelpButton che spiega la funzione.
**Testo del tastino di aiuto (fornito dall'utente, da usare verbatim):**
"Create a will that does not require a Will-executor; it can be saved, for example,
on a USB stick, and a copy can be given to the heirs."
**SCOPERTA IMPORTANTE (DISCOVER gia' fatto):**
- La config ESISTE GIA': `self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)`
in `bal/core/plugin_base.py:193` (default True = ON). Quindi NON va creata.
- Il checkbox del wizard e' in `bal/gui/qt/lists.py:916-918`:
hbox.addWidget(QLabel(_("Add transactions without willexecutor")))
heir_no_willexecutor = BalCheckBox(self.bal_plugin.NO_WILLEXECUTOR)
-> usa BalCheckBox legato alla stessa config. Aggiungendo lo stesso checkbox nelle
settings, i due rimangono sincronizzati automaticamente (stessa BalConfig).
- La finestra Settings e' `settings_dialog()` in `bal/gui/qt/plugin.py:372`.
Usa una griglia con la helper `add_widget(grid, label, widget, row, help_)`
(definita in `bal/gui/qt/common.py:98`) che mette: QLabel(col0), widget(col1),
HelpButton(col2). Le righe attuali vanno 1..8 (8 = bottone Rebroadcast).
- Esiste gia' codice COMMENTATO che faceva esattamente questo (plugin.py:382 e
536-542): `# heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)` e un
add_widget "Backup Transaction" -> si puo' riattivare/adattare.
- C'e' anche un blocco "Reset setting" (on_reset_defaults, plugin.py:560-596) con
una lista `resets = [...]`: per coerenza la nuova checkbox va AGGIUNTA a quella
lista cosi' il Reset la riporta al default (ON).
**PLAN bozza (da rifinire e far approvare quando si avvia):**
1. In `settings_dialog()`: creare `heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)`.
2. Aggiungere una riga con `add_widget(grid, "<label>", heir_no_willexecutor, <row>, "<help text utente>")`.
- Decidere label breve (es. "No will-executor" / "Backup will (no will-executor)") -> CHIEDERE ALL'UTENTE quale label preferisce nella colonna sinistra.
- Decidere la riga: inserire prima del Rebroadcast (riga 8), eventualmente rinumerando le righe successive.
3. Aggiungere `(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check")` alla lista `resets`
in on_reset_defaults, cosi' il "Reset setting" la riporta a ON.
4. Mantenere lo stile esistente (HelpButton, griglia). Default gia' ON via config.
**DOMANDA DA PORRE PRIMA DI EXECUTE:** quale etichetta breve mostrare nella colonna
sinistra delle settings? (l'utente ha fornito solo il testo del tastino di aiuto).
- Seguire METHOD: DISCOVER(fatto) -> PLAN (attendere OK) -> EXECUTE -> VERIFY -> ZIP-first -> commit/PR/release solo dopo conferma.
## TASK D (proposto, NON ancora avviato) — Rinominare il tastone del wizard
**Origine:** richiesta utente del 23/06/2026.
Il tastone grande che apre il wizard mostra "Create your will" e l'utente vuole
cambiarlo in "Build your will" (aveva scritto "BUIL YOUR WILL" -> refuso per BUILD).
**SCOPERTA (DISCOVER gia' fatto):**
- Il bottone e' in `bal/gui/qt/lists.py:473`:
wizard = QPushButton(" " + _("Create your will"))
- INCOERENZA esistente: il tooltip dello STESSO bottone (lists.py:483) dice gia'
wizard.setToolTip(_("Wizard - Build your will"))
e tutti i commenti del codice (8 occorrenze) chiamano il wizard "Build your will".
Quindi cambiare il testo del bottone in "Build your will" UNIFORMA tutto.
**PARERE AGENTE (concordato con l'utente):** "Build your will" e' la scelta migliore
(coerenza con tooltip/commenti/config; "Build" comunica meglio il processo guidato a step).
**FORMA SCELTA DALL'UTENTE:** "Build Your Will" (title case).
**PLAN bozza:** sostituire la sola stringa "Create your will" -> "Build Your Will"
(title case) in `lists.py:473`. Verificare che non ci siano altre occorrenze del
testo del bottone da allineare. Lasciare invariato il tooltip (gia' "Build your will").
- Seguire METHOD: DISCOVER(fatto) -> PLAN (attendere OK) -> EXECUTE -> VERIFY -> ZIP-first -> commit/PR/release solo dopo conferma.
================================================================
## PENDING TASK (post-v0.3.9) — UNIFY INVALIDATE PROCEDURE — NOT STARTED
## Status: WAITING for user to provide MORE input before planning/acting.
## User said: "OPZIONE A ... ma aspetta prima di agire, che ho altro da darti in pasto"
## then refined the requirement (see below), then: "pero attendi altro, intanto salva questo punto"
================================================================
### GOAL: make the "invalidate" procedure IDENTICAL for both CHECK button and WIZARD.
Currently there are TWO different invalidate procedures (user noticed the inconsistency):
- PROCEDURE 1 "classic/manual" = window.py::invalidate_will (line 695):
waiting dialog + "please sign and broadcast" popup + CLASSIC Electrum tx window
(Sign/Broadcast buttons) + SETS history label "BAL Invalidate transaction" (line 704).
Used by: Tools->Invalidate menu (lists.py:468->571), dialog button (dialogs.py:1356),
on-close/postpone paths (window.py:539,576,614), and the wizard-add-heir popup (my v0.3.9).
- PROCEDURE 2 "automatic" = dialogs.py::invalidate_task (line 902):
password prompt inside wizard -> sign + auto broadcast (loop_broadcast_invalidating, line 729)
-> DOES NOT set the history label. Used by CHECK button (lists.py:545 -> BalBuildWillDialog.build_will_task)
and the FIRST WillExpiredException handler in task_phase1 (dialogs.py:594 -> return None, Will.invalidate_will at 598).
### CHECK button flow: lists.py:545 check() -> BalBuildWillDialog(...).build_will_task()
-> task_phase1 -> on_success_phase1. SAME engine as wizard.
- If expired immediately -> FIRST handler (dialogs.py:594) -> return None, Will.invalidate_will -> on_success_phase1 have_to_sign is None -> password prompt "Invalidate your old will" -> invalidate_task (PROCEDURE 2, NO label).
- If heir added -> HeirNotFoundException -> build_will -> inner check expired -> "invalidate_classic" signal -> my v0.3.9 popup (Tools->Invalidate).
### FINAL REQUIREMENT (user, latest):
1. CHECK and WIZARD must behave IDENTICALLY.
2. First show a WARNING popup (no more "use the top-right menu Tools -> Invalidate" text).
3. Then AUTOMATICALLY open the CLASSIC Electrum sign window (PROCEDURE 1 / window.py::invalidate_will),
which sets the "BAL Invalidate transaction" label and lets the user Sign + Broadcast.
4. So: warning popup -> user clicks OK -> classic sign window opens BY ITSELF, IN FRONT.
### APPROACH agreed-in-principle (still need final OK + user has MORE input coming):
- Close the CHECK/wizard dialog FIRST, show warning, then open classic window LAST so it stays in front
(the focus problem before came from the wizard closing AFTER the tx window opened).
- Unify: route the expired cases (FIRST handler + invalidate_classic + CHECK) to the SAME helper that
shows the warning then calls window.py::invalidate_will (PROCEDURE 1). Drop PROCEDURE 2 (invalidate_task) usage for expired.
- KNOWN RISK: auto-opening the classic window while a dialog closes previously put it BEHIND the wallet on the user's PC.
Mitigation: ensure the classic window is the LAST thing opened (nothing closes after it). Tools->Invalidate works
perfectly precisely because nothing else is closing.
### WARNING POPUP TEXT (verbatim, user-approved, English per R1):
"Your will has expired and must be invalidated before it can be rebuilt.
A transaction window will now open:
please SIGN and then BROADCAST it to invalidate your old will.
After the invalidation is confirmed, press the Check button to finish the will."
(NOTE: keep the exact wording incl. double space "and then BROADCAST" as user wrote it? -> ASK / normalize to single space.)
### DO NOT ACT YET. Wait for user's additional input. Then: full PLAN -> wait OK (R4) -> zip-first.
================================================================
## FUTURE TASK (analysis only, NOT started) — SIMPLE / ADVANCED mode
## Status: DISCOVER done. No PLAN yet, no code. User said "intanto fa l'analisi e tieni tutto qui".
================================================================
### USER REQUEST
Add a SIMPLE / ADVANCED switch in the plugin settings panel.
- SIMPLE (DEFAULT): hide RAW mode and the CHECK ALIVE parameter from the UI.
- ADVANCED: everything visible as today.
- A wallet that already used RAW / has advanced wills must open in ADVANCED mode.
- MUST keep compatibility with existing wallets that have OLD inheritances.
- User asked whether it is feasible without rewriting the engine.
### USER CLARIFICATIONS (verbatim meaning)
1. CHECK ALIVE = proof the user is still alive: opening Electrum proves you are alive;
if you open the plugin AFTER the check-alive date, it proposes creating a postponed
(anticipated/rescheduled) inheritance.
2. In SIMPLE mode, locktime entry uses normal DATA mode (calendar), not RAW.
3. A wallet that had RAW should open the plugin in ADVANCED mode.
### TECHNICAL FINDINGS (DISCOVER)
- CHECK ALIVE = `will_settings["threshold"]` (a timestamp). Read in
window.py::init_class_variables (~459): `date_to_check = BalTimestamp(threshold).to_timestamp()`;
if `date_to_check < now` -> raises CheckAliveError (~469). Default relative dates:
threshold "30d", locktime "1y" (plugin_base.py:341-342; defaults built at ~320-337).
`threshold` is NOT used inside will.py (the tx-core); it lives in will_settings and
drives the "are you still alive / postpone" prompt.
- DELIVERY TIME = `will_settings["locktime"]` = the actual tx locktime. ESSENTIAL, untouched.
- RAW / DATA = only an INPUT MODE in the UI (widgets.py): RAW = type "30d"/"1y"
(TimeRawEditWidget / LockTimeRawEdit ~378/411); DATA = pick a calendar date.
Combo defined ~253 options ["Raw","Date"]. The SAVED value is always a date/timestamp,
so RAW vs DATA does NOT change what is stored in the will -> NO compatibility impact.
- ThresholdTimeWidget (widgets.py:559) = the CHECK ALIVE editor (base_field="threshold",
label "🚨"). LockTimeWidget (591) = DELIVERY TIME (base_field="locktime", label "🚛").
- Help text for CHECK ALIVE already documents DATA vs RAW behaviour (widgets.py:562-575).
### FEASIBILITY VERDICT
- Feasible: YES.
- Rewrite engine from scratch: NOT NEEDED and NOT RECOMMENDED. CHECK ALIVE and RAW sit
ON TOP of the engine. SIMPLE mode = UI hiding + sensible defaults, NOT an engine rewrite.
- Compatibility with old wallets: PRESERVED, because the engine keeps reading the same
threshold/locktime timestamps.
### PROPOSED APPROACH (to be turned into a PLAN later, then wait OK)
- New config flag e.g. BalConfig "bal_ui_mode" / SIMPLE default (or a boolean ADVANCED=False).
- SIMPLE: force locktime editor to DATA mode and HIDE the Raw/Date combo; HIDE the CHECK
ALIVE (threshold) field; set threshold to a safe default automatically so
init_class_variables still works (DECISION NEEDED: what default? e.g. threshold = locktime,
or threshold = locktime minus a small delta, or disable the check-alive prompt entirely
in SIMPLE). ASK THE USER what CHECK ALIVE should default to in SIMPLE before coding.
- ADVANCED: current behaviour, all fields visible.
- AUTO-DETECT advanced wallets: if an existing wallet/will was created with RAW or has a
non-default threshold, open in ADVANCED automatically (per user rule #3). DECISION NEEDED:
exact detection criterion (e.g. threshold != default, or a stored marker).
- Engine (will.py, heirs.py) stays UNCHANGED -> compatibility preserved.
### OPEN QUESTIONS TO ASK BEFORE PLAN
1. In SIMPLE mode, what should CHECK ALIVE do by default? (a) disabled/no prompt,
(b) auto-set to a value (which?), (c) something else.
2. Exact rule to auto-detect "advanced" wallets to force ADVANCED mode on open.
3. Should the SIMPLE/ADVANCED switch be global (plugin-wide) or per-wallet?
### DO NOT ACT. Analysis stored. Wait for user to resume + answer open questions.
----------------------------------------------------------------
## SIMPLE/ADVANCED — USER DECISIONS (round 2) — still analysis only, DO NOT code
----------------------------------------------------------------
### DECISION 1 — CHECK ALIVE in SIMPLE mode
In SIMPLE mode, CHECK ALIVE must behave AS IF IT DID NOT EXIST as a parameter that
influences rewriting the inheritance. I.e. it must NOT trigger the "you are alive ->
postpone / rewrite the will" behaviour. So in SIMPLE mode the check-alive prompt is
effectively NEUTRALIZED (no postpone proposal driven by threshold).
TECHNICAL IMPLICATION: window.py::init_class_variables (~459-470) computes
date_to_check from will_settings["threshold"] and raises CheckAliveError if it is in
the past. In SIMPLE mode we must avoid that path influencing rewrites -> e.g. set
threshold so it never triggers (or skip the check-alive logic entirely when mode==SIMPLE).
Exact mechanism to be decided in PLAN, but the INTENT is: SIMPLE = no check-alive effect.
### DECISION 2 — persistence of the mode
- NEW wallets/wills: store the mode (SIMPLE or ADVANCED) as a piece of information saved
WITH the wallet (per-wallet), via wallet.db (same place as "will": window.py uses
self.wallet.db.get_dict("will"); plugin_base.py registers dicts e.g.
json_db.register_dict("will_settings", ...) at line 55). So add a stored marker, e.g.
in will_settings or a dedicated db key (to be decided in PLAN).
- OLD wallets that do NOT have this info: open by DEFAULT as ADVANCED.
(This also satisfies rule #3 from round 1: a wallet that used RAW opens in ADVANCED,
because old wallets default to ADVANCED.)
### DECISION 3 — the SIMPLE/ADVANCED switch is GLOBAL (plugin-wide)
- The toggle itself lives in the plugin settings panel as a GLOBAL config
(a BalConfig, like the others in plugin_base.py ~146-211), default SIMPLE.
- BUT each wallet also remembers the mode it was created/saved with (decision 2).
-> PLAN must reconcile: global switch default = SIMPLE, yet an existing/old wallet
opens ADVANCED, and new wallets persist their mode. Need to define precedence:
likely the per-wallet stored mode wins when present; the global switch sets the
default for NEW wallets and the global UI default. CONFIRM precedence with user in PLAN.
### PERSISTENCE MECHANISMS CONFIRMED (DISCOVER)
- Per-wallet data: self.wallet.db.get_dict("will") (window.py:133). will_settings dict
registered via json_db.register_dict("will_settings", ...) (plugin_base.py:55).
- Global plugin settings: BalConfig wrappers over Electrum config (plugin_base.py:58, 146+).
- => GLOBAL switch = new BalConfig; PER-WALLET mode marker = new key in wallet.db /
will_settings. Both feasible, engine untouched.
### STILL OPEN FOR PLAN (ask/confirm before coding)
- Precedence rule when global switch and per-wallet stored mode disagree.
- Exact SIMPLE-mode mechanism to neutralize check-alive (set threshold vs skip logic).
- Where exactly to store the per-wallet mode marker (will_settings key name).
- What other ADVANCED-only UI elements to hide in SIMPLE besides RAW combo + CHECK ALIVE
(e.g. multiverse, editable dates, num reminders?) -> ASK user for the full SIMPLE list.
### DO NOT ACT. Wait for user to resume. Then full PLAN -> wait OK (R4) -> zip-first.
================================================================
## NEW TASKS (added by user, latest message) — TO-DO LIST ONLY, NOT STARTED
## User: "ti aggiungo altri punti, da mettere in lista delle cose da fare"
## NO coding yet. Each needs DISCOVER(refine) -> PLAN -> wait OK (R4) -> zip-first.
================================================================
### TASK #01 — Fix misleading "heir not found" message after wizard when date was only anticipated
**User (verbatim, IT):** "dopo aver fatto una rendita e il wizard, il plugin dice 'heir not found',
ma in realtà è stato solo anticipata la data, il messaggio informativo è sbagliato, ma il resto funziona bene."
**Problem:** the FUNCTIONAL behaviour is correct (the will is rebuilt with the anticipated/postponed
date). Only the INFORMATIONAL message is wrong/misleading: it says "Heir not found" when actually the
date was simply anticipated.
**TECHNICAL CONTEXT (already discovered):**
- The message comes from dialogs.py::task_phase1, in the `except NotCompleteWillException as e:` block
(~625). At lines ~641-642:
elif isinstance(e, HeirNotFoundException):
message = _("Heir not found")
- HeirNotFoundException is a subclass of NotCompleteWillException and is raised by will.py::search_rai
(check_will order at will.py:561: check_invalidated -> check_will_expired -> search_rai).
- So when an heir is added / date anticipated, the rebuild path raises HeirNotFoundException and the
user sees "Heir not found", which is misleading.
**DISCOVER to refine when starting:** confirm in which exact scenario(s) HeirNotFoundException is raised
during a normal anticipate/postpone flow; decide the correct, non-alarming wording (English, R1).
Possibly distinguish "genuine heir-not-found error" vs "date anticipated, rebuilding" so the message is
accurate in both cases. ASK user for preferred wording if ambiguous (R3).
### TASK #02 — Will-executor server list: green-check ONLY servers that responded + green ping; re-evaluate each download
**User (verbatim, IT):** "quando scarico dal wizard la lista dei will executor, il plugin deve spuntare
di verde solo i server che hanno risposto correttamente, e che hanno il pallino del ping verde.
altrimenti poi si impalla tutto il plugin e continua a fare broadcast a quelli che non rispondono bene.
meglio scartare alla fonte i server che non rispondono subito bene. questo deve valere ogni volta che
scarico la lista server; se la seconda volta scarico e un server che prima non rispondeva ora risponde,
il plugin lo aggiunge alla lista server."
**Goal:**
1. When downloading the will-executor list from the wizard, auto-select (green check) ONLY servers that
(a) responded correctly AND (b) have a GREEN ping dot.
2. Discard non-responsive servers AT THE SOURCE (do not select / do not broadcast to them) so the plugin
does not get stuck continuously broadcasting to dead/slow servers.
3. This must apply EVERY time the list is downloaded. On a later download, if a previously non-responsive
server now responds correctly, it gets added/selected again.
**TECHNICAL CONTEXT (already discovered, to refine in DISCOVER):**
- Download/selection logic: bal/core/willexecutors.py (get_willexecutors, get_willexecutor_transactions,
is_selected, push logic). Willexecutors.is_selected(...) decides the green checkmark.
- Wizard download window/checkbox UI is in lists.py (download list ~917 area).
- The push/broadcast loop is dialogs.py::loop_push (~750), which contacts SELECTED servers.
- NEED: filter out servers that don't respond / lack green ping BEFORE selecting them, and re-run this
evaluation on every download (so the selected set reflects current health each time).
**DISCOVER to refine when starting:** find exactly where the ping/green-dot status is computed and where
is_selected is set after a download; decide the criterion ("responded correctly" + "green ping") and how
to re-evaluate on each download without losing manual user choices (ASK if conflict). Engine of will.py
untouched.
### TASK #03 — Invalidate tx missing "BAL Invalidate transaction" history label when invalidate done from the AUTO-opened window
**User (verbatim, IT):** "come già detto prima con te, la transazione invalidate non ha scritto
'BAL invalidate transaction' in cronologia del wallet, se fatto invalidate dalla finestra che si apre
in automatico. mentre lo scrive solo quando faccio invalidate dal menu TOOLS, Invalidate manualmente."
**Problem:** the history label "BAL Invalidate transaction" is written ONLY when invalidating manually via
Tools -> Invalidate (PROCEDURE 1), NOT when invalidating from the window that opens automatically
(PROCEDURE 2).
**TECHNICAL CONTEXT (already discovered):**
- PROCEDURE 1 (manual, GOOD): window.py::invalidate_will (~695) sets the label at ~704:
self.wallet.set_label(result.txid(), "BAL Invalidate transaction")
- PROCEDURE 2 (automatic, MISSING label): dialogs.py::invalidate_task (~902) +
loop_broadcast_invalidating (~729). Neither calls set_label. The FIRST WillExpiredException handler
in task_phase1 (dialogs.py ~594-607 -> return None, Will.invalidate_will) routes the CHECK button to
PROCEDURE 2, which is why the label is missing.
**OVERLAP:** this is the SAME root cause described in the saved "PENDING TASK — UNIFY INVALIDATE
PROCEDURE" above. Fixing the unify task (route all expired-invalidate cases to PROCEDURE 1, which sets
the label) would also fix TASK #03. Keep them linked: solving "unify invalidate" with auto-open of the
classic window (PROCEDURE 1) resolves #03 automatically. If implemented separately, the minimal fix is to
add set_label("BAL Invalidate transaction") in the PROCEDURE 2 broadcast path.
### DO NOT ACT on #01/#02/#03. Saved to to-do list only. Wait for user to add more or to choose one;
### then DISCOVER -> PLAN -> wait OK (R4) -> zip-first.
---
## PLUGIN STATES TABLE (requested in TASK #01 — for .md documentation)
**Source of truth:** bal/core/will.py `STATUS_DEFAULT` (lines 898-917) + side-effect rules in
`set_status` (lines 919-970); "Server" column derived in bal/gui/qt/theme.py `server_status_text`
(lines 63-83). Built by READING the code (R3 — not invented). The visible "Stato" column is a
COMPOSITE string built by appending each flag as it is set: `status += "." + name`, with "NOT "
prepended when a flag is cleared (will.py:952). That is why screenshots show chains like
"New.Firmato.Pushed.Checked.Confirmed".
### A. Individual status flags (STATUS_DEFAULT) — 18 flags
Each flag is `[label, default_value]`. Default `value` is the initial boolean.
| Key (code) | Label shown | Default | Meaning (WHY it exists) |
|--------------|---------------|:-------:|--------------------------|
| ANTICIPATED | Anticipated | False | Locktime moved earlier by 1 day. KEEPS VALID on purpose (still deliverable). |
| BROADCASTED | Broadcasted | False | The transaction was broadcast to the Bitcoin network. |
| CHECKED | Checked | False | Will-executor server confirmed it holds the tx. Setting it also sets PUSHED and clears PUSH_FAIL. |
| CHECK_FAIL | Check Failed | False | The post-push verification on the server failed. |
| COMPLETE | Signed | False | The will transaction has been fully signed. |
| CONFIRMED | Confirmed | False | Tx confirmed on-chain. Clears VALID and clears INVALIDATED. |
| ERROR | Error | False | A generic error state for the item. |
| EXPIRED | Expired | False | The will is past its locktime (delivery time reached). |
| EXPORTED | Exported | False | The will item was exported (backup/share). |
| IMPORTED | Imported | False | The will item was imported from external data. |
| INVALIDATED | Invalidated | False | A spend invalidated this will. Clears VALID. Cleared again if CONFIRMED/MEMPOOL. |
| MEMPOOL | Mempool | False | Tx seen in the mempool. Clears VALID and clears INVALIDATED. |
| PUSH_FAIL | Push failed | False | Sending the tx to the will-executor server failed. |
| PUSHED | Pushed | False | Tx was sent (pushed) to the will-executor server. Setting it clears PUSH_FAIL and CHECK_FAIL. |
| REPLACED | Replaced | False | Superseded by another will tx. Clears VALID. |
| RESTORED | Restored | False | Item restored (e.g. from a backup). |
| UPDATED | Updated | False | Replaced by a new tx with SAME locktime + SAME heirs. KEEPS VALID on purpose. |
| VALID | Valid | True | The only flag that starts True. The will is currently a valid, deliverable tx. |
### B. Side-effect rules (state machine) — will.py set_status (lines 955-969)
- INVALIDATED, REPLACED, CONFIRMED, MEMPOOL -> clear VALID.
- CONFIRMED, MEMPOOL -> also clear INVALIDATED (tx is on-chain/in mempool).
- PUSHED -> clear PUSH_FAIL and CHECK_FAIL.
- CHECKED -> set PUSHED, clear PUSH_FAIL.
- ANTICIPATED and UPDATED -> intentionally KEEP VALID (not in the clear list).
### C. "Server" column (online will-executor state) — theme.py server_status_text, priority order
Evaluated top-to-bottom; first match wins.
| Condition (flags) | Text shown |
|-------------------------------------------|-----------------------|
| CHECK_FAIL AND NOT CHECKED | Not on server |
| CHECKED | Confirmed on server |
| PUSH_FAIL | Send failed |
| PUSHED | Sent (not checked) |
| COMPLETE | Signed (not sent) |
| (none of the above) | Not sent |
### D. Example composite "Stato" chains (from screenshots) and how they form
- "New" -> fresh item, only VALID true (label may show "New").
- "New.Firmato" -> + COMPLETE (Signed; "Firmato" = old/IT locale).
- "New.Firmato.Pushed" -> + PUSHED.
- "New.Firmato.Pushed.Checked" -> + CHECKED (also implies PUSHED).
- "New.Firmato.Pushed.Checked.Confirmed" -> + CONFIRMED (clears VALID under the hood).
- "New.Firmato.Pushed.Check Failed.NOT Pushed" -> CHECK_FAIL then a later clear of PUSHED -> "NOT Pushed".
NOTE: the composite string is APPEND-ONLY history of flag changes, so it can contain both a flag and
its later "NOT <flag>" negation. The boolean truth is in STATUS[...][1], not in the visible chain.
### NOTE for #01 fix: the misleading "Heir not found" message is a SEPARATE UI string in
dialogs.py task_phase1 (~641-642), NOT a status flag. The states table above documents the engine
flags; #01 is about correcting the human message when the real cause is a date anticipation.
---
## CHECK-WINDOW MESSAGES LIST (requested by user — outcomes shown in the info window after CHECK)
**Source of truth:** bal/gui/qt/dialogs.py `BalBuildWillDialog` — task_phase1 (~542-707), the message
helpers (msg_set_checking/building/signing/pushing/invalidating ~1204-1230), and the color/result
helpers msg_ok/msg_warning/msg_error (~1234-1245). Colors: OK=green #05ad05, WARNING=orange #cfa808,
ERROR=red #ff0000. Built by READING the code (R3 — not invented).
NOTE: this is DIFFERENT from the "PLUGIN STATES TABLE" above. The states table lists the will-item
flags (the "Stato" column). THIS list is the human-readable progress/outcome lines printed in the
"Building Will" info window when the user presses CHECK (or runs the wizard). The window has fixed
ROW LABELS and, for each, a variable STATUS text.
### Row labels (left side, fixed) in the CHECK / build window
1. "Checking variables"
2. "Checking your will"
3. "Building your will"
4. "Invalidating old will" (only when an invalidation is triggered)
5. "Signing your will" (phase 2)
6. "Broadcasting your will to executors" (phase 2)
### ROW 1 — "Checking variables" outcomes
| Status text shown | Color | When (code) |
|--------------------------------------------------------------------------------|--------|-------------|
| Check Alive Threshold Passed: you have to Invalidate your old Will | RED | CheckAliveError + an invalidate tx exists (~559) |
| No Heirs | RED | NoHeirsException (~564) |
| Ok | GREEN | check_amounts passed (~579) |
| In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts | (warning popup) | AmountException (~583) |
### ROW 2 — "Checking your will" outcomes (this is where "Heir not found" / "New" appear)
| Status text shown | Color | When (code) | Triggers rebuild? |
|----------------------------------|---------|-------------|:-----------------:|
| Ok | GREEN | check_will passed, nothing to change (~593) | no |
| Expired | (plain) | WillExpiredException (~596) -> invalidate path | invalidate |
| Postponed: invalidating old will | (plain) | WillPostponedException (~609) -> invalidate path | invalidate |
| No Heirs | (plain) | NoHeirsException (~614) | no |
| Heirs changed: | (plain) | HeirChangeException (~621) | YES |
| Will-Executor not present | (plain) | WillExecutorNotPresent (~623) | YES |
| Will-Executor changed | (plain) | WillexecutorChangeException (~625) | YES |
| Txfees are changed | (plain) | TxFeesChangedException (~627) | YES |
| Heir not found | (plain) | HeirNotFoundException (~629) [#01: MISLEADING — often it is just an anticipated date] | YES |
| New | (plain) | NotCompleteWillException with no specific subtype (~634) [a brand-new will item, "New heir" case] | YES |
### ROW 3 — "Building your will" outcomes
| Status text shown | Color | When (code) |
|----------------------------------------------------------------|---------|-------------|
| Ok | GREEN | build_will succeeded (~652) |
| Balance is too low, or CheckAlive is in the past.Skipped | RED | build_will returned nothing (~643) |
| Will-Executor excluded -> Skipped | RED | WillExecutorNotPresent during build (~656) |
| <expired notice from exception> | ORANGE | WillExpiredException after rebuild -> returns "invalidate_classic" (~683) |
| <error text> | RED | any other Exception during build (~688) |
| <heir>,<amount> is DUST -> Excluded from will <wid> | ORANGE | a heir's amount is DUST (~695-699) |
### ROW 4 — "Invalidating old will" outcomes (loop_broadcast_invalidating ~729)
| Status text shown | Color | When |
|------------------------|-------|------|
| Broadcasting | plain | start of broadcast |
| Ok | GREEN | broadcast succeeded |
| <broadcast error msg> | RED | TxBroadcastError / BestEffortRequestFailed |
### ROW 5 — "Signing your will" (phase 2) — set via msg_set_signing (~1220)
Shows progress while signing; final result via msg_ok / msg_error.
### ROW 6 — "Broadcasting your will to executors" (phase 2, loop_push ~750)
| Status text shown | Color | When |
|------------------------------------------------|-------|------|
| Broadcasting N/M (Xs / Ys) | plain | live progress per selected will-executor |
| <url> : Ok | GREEN | that server accepted the tx (-> PUSHED) |
| <url> : Ko | RED | that server rejected (-> PUSH_FAIL) |
| <url> : Timeout - no answer | RED | server did not answer in time (-> PUSH_FAIL) [related to #02] |
| checking <url> - <wid> : Waiting | plain | verifying an "already present" server |
| checked <url> - <wid> : <True/False> | GREEN/RED | post-push verification result (-> CHECKED) |
### LINK TO #01: the "Heir not found" line (ROW 2) is exactly the misleading message. The fix is to
distinguish the real cause: if the will item only had its date anticipated (locktime moved earlier),
show a correct message instead of "Heir not found". DISCOVER will confirm the precise condition.
---
## TASK #01b — Replace misleading "Heir not found" / "New" messages (NOT STARTED — analysis only)
**User decision (Opzione 2):** change BOTH the CHECK window (dialogs.py) AND window.py for consistency.
**User said:** "ma metti tutto in lista non fare modifiche per ora" (just add to list, do NOT modify yet).
### GOAL
Replace the two MISLEADING outcome texts shown in ROW 2 "Checking your will" with a single clear text:
**NEW TEXT (verbatim, FINAL/approved — TWO LINES):**
```
Found CHANGES to the DATE or the HEIRS,
a NEW WILL must be prepared.
```
In code this is a single string with a newline: `"Found CHANGES to the DATE or the HEIRS,\na NEW WILL must be prepared."`
(No trailing quote character; the message ends at "prepared." Note: "CHANGES" is UPPERCASE.)
### SCOPE — exactly 3 string changes (Opzione 2: A + B for consistency)
| # | File:line | Current text | Flow | Action |
|---|-----------|-------------------------|----------------------------------------|--------|
| 1 | bal/gui/qt/dialogs.py:631 | `_("Heir not found")` | CHECK window / wizard (task_phase1) | -> new text |
| 2 | bal/gui/qt/dialogs.py:636 | `"New"` (msg_set_checking) | CHECK window / wizard (task_phase1) | -> new text |
| 3 | bal/gui/qt/window.py:594 | `"Heir not found"` | build_inheritance_transaction (popup via show_message) | -> new text |
### IMPORTANT — what to LEAVE UNCHANGED (do NOT touch)
In the SAME NotCompleteWillException block there are 4 OTHER specific messages that stay AS-IS,
because they are still correct/useful:
- "Heirs changed:" (HeirChangeException)
- "Will-Executor not present" (WillExecutorNotPresent) [window.py uses "Will-Executor not present:"]
- "Will-Executor changed" (WillexecutorChangeException)
- "Txfees are changed" (TxFeesChangedException)
ONLY the HeirNotFoundException branch and the no-subtype/"New" fallback are replaced.
### TECHNICAL NOTES (DISCOVER done)
- dialogs.py block: lines 618-636, inside task_phase1, `except NotCompleteWillException as e:`.
- line 631: `elif isinstance(e, HeirNotFoundException): message = _("Heir not found")`
- line 635-636: `else: self.msg_set_checking("New")` (the no-specific-subtype fallback = "New").
- window.py block: lines 578-599, inside build_inheritance_transaction, same exception ladder.
- line 594: `elif isinstance(e, HeirNotFoundException): message = "Heir not found"`
- NOTE: window.py has NO "New" fallback (if message stays False, it shows nothing) -> only the
HeirNotFoundException line is changed here. The other branches stay.
- Both blocks then call build/rebuild; behaviour (the rebuild) is UNCHANGED — only the human text changes.
- "New" string is also the basis for the composite "Stato" column? NO — that "New" is a separate UI
label; this task only touches the CHECK-window message fallback, not the will-item status flags.
### OPEN QUESTION to confirm at PLAN time (before coding)
- dialogs.py:636 currently passes the bare string `"New"` (NOT wrapped in `_()`), so it is not
translatable. When replacing, wrap the new text in `_( ... )` for both files for consistency? (default: YES.)
### METHOD when starting (R4): DISCOVER (done) -> PLAN (wait OK) -> EXECUTE -> ruff + full tests ->
### ZIP for user to test -> commit ONLY after explicit confirmation. Add a numbered CHANGELOG entry.
---
## TASK BATCH #17 - v0.4.0 USER-TEST FEEDBACK (A-K) - DISCOVER done, PLAN pending OK
User tested ZIP v0.4.0 and reported 11 points. R4: analyze + propose, DO NOT code yet.
### UI text / label fixes (low risk)
- (A) widgets.py LockTimeWidget.help_text (~607): insert a line "(ONLY IN ADVANCED MODE)<br>"
right BEFORE the "if you choose Raw, you can insert various options based on suffix:" line.
- (B) plugin.py: red warning QLabel (lbl_warning ~465, shown at top via outer.addWidget ~687).
Add a blank vertical space below it, above the grid (User Type row). Use addSpacing.
- (C) plugin.py:478 add_widget label "USER TYPE" -> "User Type".
- (D) plugin.py:519 add_widget label "Editable dates" -> "Panel editable Date and Fee".
Verify it fits before the checkbox (label is column 0 of the grid; should be fine).
- (G) plugin.py: move the "No will-executor TX" checkbox (currently row 8, ~576) to between
"Editable dates"(row4) and "Number of reminders"(row5). Requires renumbering rows 5..8 (+1).
### Layout (medium risk)
- (H) widgets.py WillSettingsWidget vertical layout (~674-722, the WIZARD).
Requirements: calendar/date box narrower; left-aligned & tidy; fee box width fit to ~5 chars;
ADVANCED mode must stay aligned when the check-alive (threshold) row also appears.
### Wait-time (low risk)
- (J) Reduce 30s waits to 20s. Locations found:
willexecutors.py:46 PUSH_GLOBAL_DEADLINE = 30 -> 20
willexecutors.py:56 CHECK_GLOBAL_DEADLINE = 30 -> 20
(window.py:1226 ping_deadline derives from PUSH_GLOBAL_DEADLINE; window.py:1166
download_deadline=45 is a separate "45s" value - user said only the 30s ones; leave 45 unless asked.)
Also comments at willexecutors.py:35,50,294 mention "30s sleeps" (text only, no behaviour).
### BUGS (high risk - need careful logic work)
- (E) "eredus" shown in RED on "Building your will:" line.
ROOT CAUSE: dialogs.py:734-735 `except Exception as e: self.msg_set_building(self.msg_error(e))`.
build_will() (window.py:309) raises an exception whose str(e) is the heir name ("eredus"),
shown as red error. Likely HeirNotFoundException or similar raised inside get_transactions.
QUESTION FOR USER ALREADY ASKED: with 10 heirs does it list all 10 here? -> Answer: the
"Building your will:" line shows ONE status only; the per-heir lines that CAN appear are the
DUST-exclusion warnings (dialogs.py:739-748), one line per heir that is DUST. So normally NOT
all 10 are listed; only DUST heirs get an extra line. Need to confirm which exception carries
the heir name to decide the fix (turn into green Ok, or a clear message instead of raw name).
- (F) After DELETING one of two heirs: window says "Found CHANGES... a NEW WILL must be prepared"
(good) but then "Signing: Nothing to do" / "Broadcasting: Nothing to do", and on exit the WILL
tab gets the inheritance tx list but they are NOT signed. Pressing CHECK re-opens same window,
still "Nothing to do".
RELEVANT CODE: dialogs.py have_to_sign loop (750-754): have_to_sign=True only if some valid
willitem is NOT COMPLETE. on_success_phase1 (1077): else branch -> msg_set_signing("Nothing to
do") when have_to_sign is False. task_phase2 only signs if have_to_sign.
SUSPECT: will.py update_will (366-375): when a txid is unchanged between old/new will, it REUSES
the OLD willitem object (which is COMPLETE), so freshly-rebuilt items inherit COMPLETE status
and have_to_sign stays False. After deleting an heir the remaining tx may keep same txid ->
reused as COMPLETE -> "Nothing to do". NEEDS deeper confirm; possibly the rebuilt tx should be
re-flagged "New"/not-COMPLETE when heirs set changed. HIGH RISK - confirm with logs before edit.
- (K) BASIC, add heir from WIZARD keeping SAME date: it correctly rebuilds + signs + creates new tx
in WILL list, but does NOT auto-broadcast. User asks if it should.
RELEVANT CODE: task_phase2 (1142-1153): have_to_push True only if willitem has `.we` (a
will-executor) AND COMPLETE AND not PUSHED. If no will-executor is attached (e.g. "No
will-executor TX" mode or none selected), have_to_push stays False -> "Nothing to do" (correct,
nothing to broadcast). Need to confirm user's wallet had a will-executor selected. If yes, the
push path should fire; if it didn't, investigate selection state after wizard add-heir.
DESIGN QUESTION FOR USER: in BASIC, when AUTO_SIGN is on and a will-executor is selected,
should broadcast always be automatic? (current logic only pushes when a will-executor exists.)
### Logic verification (no code, just run)
- (I) Run full test suite to confirm BASIC mode (only DELIVERY TIME, no check-alive) works.
### PROPOSED GROUPING (for credit efficiency, one ZIP cycle)
- Group A = pure text/label/spacing/move: A, B, C, D, G (very low risk)
- Group B = layout H (calendar narrower, left-align, fee ~5 chars, ADVANCED alignment)
- Group C = wait-time J (30->20 in willexecutors.py x2)
- Group D = bug E (red heir name -> green/clear message)
- Group E = bugs F and K (sign/broadcast logic) - HIGHEST RISK, may need user logs
- Then: (I) run full tests, ruff, build ONE ZIP v0.4.1, user tests, commit only after OK.
---
## TASK BATCH #17 - UPDATE after LOG analysis (log.txt 183 lines, 2026-06-24)
### ROOT CAUSE CONFIRMED for E + F (they are the SAME bug)
Log sequence on CHECK after heir change:
1. check_willexecutors_and_heirs -> "heir: erede002new not found" -> HeirNotFoundException
-> "not complete erede002new true" -> message "Found CHANGES..." -> have_to_build=True. OK
2. build_will() -> "txs built: {3 tx}" (new tx created). OK
3. dialogs.py:697 self.bal_window.check_will() -> is_will_valid -> check_willexecutors_and_heirs
AGAIN -> "heir: erede001 not found" -> raises HeirNotFoundException("erede001").
4. That exception is caught by the GENERIC `except Exception as e:` (dialogs.py:734) ->
msg_set_building(self.msg_error(e)) => shows the HEIR NAME in RED == BUG E
then `return False, None` => have_to_sign=False => "Nothing to do" / no sign / no push == BUG F
Log proof: lines 96-98 "check willexecutors heirs / heir: erede001 not found / have to sign False".
WHY does check_will() still see the OLD heir after rebuild?
-> will.py update_will (366-375): for txid unchanged between old/new will it REUSES the OLD
WillItem object (old heirs/old we/COMPLETE). So after build_will the willitems still carry
stale heir entries -> check_willexecutors_and_heirs raises HeirNotFoundException again.
-> Confirmed by user: deleting/adding an heir must FULLY rebuild (values recomputed: single heir
auto-scaled to 100%, whole wallet always emptied). So reusing old items is wrong here.
USER WORKAROUND that worked: deleting ALL tx in WILL list, then CHECK -> correctly rebuilds & asks
to sign. This confirms: the stale reused items are the problem; with an empty will there is nothing
stale to reuse.
### FIX DIRECTION (E+F) - to confirm at PLAN:
- The post-build verification at dialogs.py:697 should NOT re-raise HeirNotFound for the freshly
rebuilt will. Options:
(a) After build_will(), the new willitems must reflect the NEW heirs (not reuse old COMPLETE
items whose heirs no longer match). i.e. update_will should NOT copy old heirs onto a tx
whose heir SET changed; only reuse when heirs are identical.
(b) Or: in dialogs.py wrap/handle the second check_will() so a HeirNotFound on the just-rebuilt
will is treated as "needs signing" (have_to_sign=True) instead of red error + return False.
- Preferred: (a) at the source (update_will) so status is correct (new tx => not COMPLETE =>
have_to_sign True => sign => push). HIGH RISK: update_will is shared by many paths; must run
full test suite. Need to be 100% sure before editing (R3).
### E - user clarified: show ALL heirs in GREEN.
Today the "Building your will:" line shows ONE status. To list all heirs in green we add, after a
successful build, one green line per heir (name) - source: willitems[wid].heirs keys (skip the
internal 'w!ll3x3c"' executor pseudo-heirs). Place where msg_set_building(msg_ok()) succeeds
(dialogs.py:701). Need a per-heir green row helper (msg_set_status with COLOR_OK).
### K - user clarified:
- "No will-executor TX" = the CELESTE/backup tx (the one NOT needing a will-executor). Checkbox
ON => also create that backup tx; OFF => only create tx that go to will-executors.
- RENAME setting label to "Add transaction without willexecutor"; if it doesn't fit before the
checkbox, use "Add TX without willexecutor". (plugin.py row, currently "No will-executor TX".)
- K bug: after the E+F fix the rebuilt tx will be not-COMPLETE => sign => push. User states 2
will-executors were selected and tx already have we associated, so once have_to_sign becomes
True the existing task_phase2 push path (have_to_push when w.we & COMPLETE & not PUSHED) should
fire automatically. So K is very likely RESOLVED by the same E+F fix. CONFIRM after fix with a
test that simulates add-heir + selected will-executors -> loop_push called.
- User decision: "when coming from the WIZARD, broadcast must ALWAYS be automatic for selected
servers". Current logic already auto-pushes when have_to_push; the missing piece was have_to_sign
being wrongly False. Keep auto-push as is.
### J - user UPDATE: also reduce download_deadline (window.py:1166, =45) to 20, and unify ALL into a
SINGLE variable if possible. PLAN: define one constant (e.g. willexecutors.NETWORK_DEADLINE = 20)
and use it for PUSH_GLOBAL_DEADLINE, CHECK_GLOBAL_DEADLINE, ping_deadline, download_deadline.
### REVISED GROUPING (one ZIP v0.4.1):
G1 text/labels/spacing/move: A, B, C, D(+ rename K-label), G (low risk)
G2 layout H
G3 wait-time J (single shared deadline constant = 20)
G4 BUG E+F+K core fix (update_will heir-set reuse OR post-build handling) + show all heirs green
Then I (full tests) + ruff -> ZIP v0.4.1 -> user test -> commit after OK.
---
## TASK BATCH #17 - DONE (v0.4.1, delivered as test ZIP, NOT committed)
Implemented G1(A,B,C,D,G + rename K-label) + G2(H layout) + G3(J unified NETWORK_DEADLINE=20)
+ G4(E+F+K core fix via Will._same_heirs Option A + all heirs green).
- New helper Will._same_heirs (will.py ~352); update_will reuses old item only if heirs identical.
- dialogs.py build-success: lists every heir in green (COLOR_OK), skips w!ll3x3c" pseudo-heirs.
- widgets.py: help_text "(ONLY IN ADVANCED MODE)"; compact left-aligned wizard date/fee layout
(date rows fixed 16-char + icon, fee field 5-char, calendar left-aligned with stretch).
- plugin.py: "User Type", "Panel editable Date and Fee", checkbox moved to row5 & renamed
"Add transaction without willexecutor", addSpacing(12) under red warning; rows renumbered.
- willexecutors.py: NETWORK_DEADLINE=20; PUSH/CHECK_GLOBAL_DEADLINE derive from it; class attr added.
- window.py: download_deadline = Willexecutors.NETWORK_DEADLINE.
- Version 0.4.0->0.4.1 (4 files). CHANGELOG entry #17.
- New tests tests/test_group_f_heir_change_rebuild.py (9). Full suite: 248 passed. ruff: no new errors.
- ZIP: bal-electrum-plugin-v0.4.1.zip, 37 files, 263683 bytes,
sha256 914954306e8e88c1ecad7be1d459cb9617a6b36fa9bf91fe2d1d8912a22621b2,
url https://www.genspark.ai/api/files/s/GLFdDxWj. NOT committed (zip-first; awaiting user OK).
---
## TASK BATCH #18 - v0.4.2 (real E/F/K fix + layout H + tooltip)
CRITICAL CORRECTION: v0.4.1's `_same_heirs`/`update_will` fix did NOT fix E/F/K.
Confirmed via electrum_log_20260624T152516Z_24316.log: rebuilt txids
(117c9f/05753d/fbb49f) are ALL NEW vs old (129ef8), so update_will's "reuse old
item if txid matches" branch never runs.
REAL ROOT CAUSE: in dialogs.py task_phase1, after build_will() the second
check_will() raises HeirNotFoundException (subclass of NotCompleteWillException),
caught by generic `except Exception` -> heir name RED + return False,None ->
"Nothing to do".
REAL FIX (v0.4.2): added `except NotCompleteWillException as e:` BEFORE generic
Exception (and after WillExecutorNotPresent/WillExpiredException). It calls
_build_success_report() (green Ok + heir list) and falls through to have_to_sign
loop -> sign -> auto-push. Extracted _build_success_report() helper used on both
clean and rebuilt paths.
Layout H (widgets.py): kept composites INTACT (they hold 2 editors + Raw/Date
combo in ADVANCED), forced all leading icons to same fixed width + left-align
stacking so icons align and fields start at same x; widened fee field to 8 chars.
Tooltip: "Delivery Time, click for more information".
STATUS: 248 tests pass, ruff clean (no new errors), version bumped to 0.4.2,
CHANGELOG entry #18 added (and #17 outcome corrected to PARTIAL). ZIP v0.4.2
built (sha256 8e40d6c3...). Delivered for testing. NO COMMIT until user confirms.
---
## TASK BATCH #19 - v0.4.3 (date-sync after auto-anticipate + BASIC calendar + sign reason)
BUG (owner, after v0.4.2 test): after a rebuild the engine auto-anticipates tx
locktime by 1 day (Will.check_anticipate), but WILL_SETTINGS["locktime"] stayed
at the original date -> next Check compared stored(orig) vs tx(orig-1d) and
mistook it for a POSTPONE -> wrongly asked to invalidate.
FIX 1 (dialogs.py): _sync_locktime_to_built_txs() in _build_success_report sets
stored locktime = Will.get_min_locktime(willitems) (min of valid built txs),
ONLY if min < current (anticipate only, never overwrite a postpone). Routed via
BalWindow.update_setting_widgets(update_all=True) so it persists AND refreshes
the date widgets in all panels/wizard; the .ics calendar reads the same alarm so
it gets the anticipated date too. Owner confirmed: min is fine when multiple txs.
FIX 2 (dialogs.py): _date_was_anticipated flag -> before the sign prompt show an
orange note explaining WHY (date moved 1 day earlier so the new will replaces the
old one; please sign+broadcast).
FIX 3 (widgets.py): BASIC calendar. check-alive is hidden in BASIC so spreading
reminders over it is wrong. New pure helper basic_reminder_offsets(days) +
BASIC_REMINDER_OFFSETS=(30,10,1): fixed reminders 30/10/1 days before delivery,
dropping any in the past. ADVANCED unchanged. The .ics now uses the (anticipated)
locktime alarm. New tests test_group_g_basic_calendar.py (7).
STATUS: 255 tests pass, ruff clean (no new errors), version -> 0.4.3, CHANGELOG
#19 added. ZIP v0.4.3 to be built and delivered. NO COMMIT until user confirms.
---
## TASK BATCH #20 - v0.4.4 (UI wording + check-alive visibility + backup-tx default OFF)
P1 (plugin.py:556): reminders help text -> BASIC (30/10/1 days before) + ADVANCED
(spread, range 1-5 default 3).
P2 (widgets.py wizard else-branch only): QLabel above date "Enter the date on
which you want the inheritance (or backup)..."; QLabel below fee "Please note: Do
not reduce the miner fees unless you know what you're doing". Wizard only.
P3 (lists.py:495, PreviewList=WILL tab): refresh tooltip "Check" -> "Check
Inheritance".
P4 BUG FIX: check-alive (threshold) visibility was set only in
WillSettingsWidget.__init__; WILL/HEIR toolbars persist across USER TYPE change so
ADVANCED didn't re-show it (only wizard, which is recreated). Added
apply_user_type_visibility() (shows threshold when not basic) called from
window.update_all() for both heir_list_widget & will_list_widget.
P5 (plugin_base.py:193): NO_WILLEXECUTOR default True -> False. Confirmed Option A
(stored in Electrum GLOBAL config via config.set_key, NOT per-wallet). Default
applies only to new installs; saved choice is respected. Reset uses cfg.default so
it's OFF too.
STATUS: 255 tests pass, ruff clean (no new errors), version -> 0.4.4, CHANGELOG
#20 added. ZIP v0.4.4 to be built/delivered. NO COMMIT until user confirms.
## TASK BATCH #21 — v0.4.5 (invalidate loop + wizard truncation)
- Issue 1: wizard QLabels truncated -> added setMinimumWidth(30*char_width_in_lineedit()) to date_hint + fee_note (widgets.py wizard else-branch ~771-810).
- Issue 2a (loop): invalidate_task wait(5)->wait(10) + set self._invalidation_broadcast=True after broadcast; on_success_phase1 (have_to_sign is None) now STOPS with clear message + _add_close_button when _invalidation_broadcast already set (no re-prompt loop). Flag initialised in __init__ ~line 546.
- Issue 2b (label): loop_broadcast_invalidating (dialogs.py:903) now sets wallet.set_label(txid, "BAL Invalidate transaction") on successful broadcast.
- Version bumped 0.4.4 -> 0.4.5 (plugin_base.py, __init__.py, VERSION, manifest.json).
- Verified: py_compile OK; 255 passed; ruff no new errors; headless label check -> 3 & 2 lines (not truncated).
- ZIP v0.4.5 delivered for testing. NO COMMIT until user confirms.
## TASK BATCH #22 — v0.4.6 (6 fixes from allegato 13-18)
- #1 DUST (dialogs.py task_phase1 ~767): dust_heirs dict de-dup -> one row PER HEIR, no willexecutor ref. Was N_exec x N_heirs rows.
- #2 Heirs (dialogs.py _build_success_report ~815): single green/bold line "Heirs: a, b, c" via shown_heirs list + one msg_set_status(_("Heirs"),...COLOR_OK).
- #3 Scroll (dialogs.py __init__ ~524 + msg_update ~1565): message_label wrapped in QScrollArea (setWidgetResizable, setMaximumHeight 400), wordWrap on label, auto-scroll to bottom (verticalScrollBar.setValue(max)). Close button stays in self.vbox below scroll = always visible.
- #4 Wizard final check (dialogs.py on_next_we ~127, case B): added same block as lists.py check() -> loop Will.needs_server_check + self.bal_window.check_transactions(will) BEFORE self.close(). Root cause: wizard only called build_will_task(), never check_transactions().
- #5 Wizard truncation (widgets.py ~771): REMOVED alignment=AlignLeft flag from date_hint+fee_note (alignment flag blocks horizontal stretch -> wordwrap on narrow sizeHint -> truncated). Added setSizePolicy(Expanding,Minimum) + self.setMinimumWidth(44*cw). Headless verified at 780px dialog -> labels expand to 740px, full text fits.
- #6 Notice (dialogs.py on_success_phase1 ~1336): yellow msg_warning -> "<b>...</b>" black bold + "\n" split after "previous one." (msg_update converts \n->br).
- Version 0.4.5 -> 0.4.6. CHANGELOG #22. 255 tests pass, ruff no new errors.
- ZIP v0.4.6 delivered for testing. NO COMMIT until user confirms.
## TASK BATCH #23 — v0.4.7 (4 fixes from testing v0.4.6)
- #1 (allegato1) Scroll height (dialogs.py __init__ ~562-563): scroll_area.setMinimumHeight(500) + setMaximumHeight(700). Was ~140px too short.
- #2 Heirs revert (dialogs.py _build_success_report ~905): REVERTED v0.4.6 "one line" -> ONE heir per line again, green/bold, de-dup via shown_heirs set, skip w!ll3x3c" pseudo-heirs. Reason: long heir names + report now scrolls.
- #3 (allegato2) Wizard line breaks (widgets.py): explicit \n after "(or backup)" in date_hint (~801) and after "miner fees" in fee_note (~831). setWordWrap honours \n.
- #4 ALL-DUST guard. KEY DECISION: guard placed at END of prepare_lists (heirs.py ~577-591), NOT in prepare_transactions. WHY: prepare_transactions only sees the single lowest locktime -> false positive if a later locktime has valid heirs. prepare_lists sees ALL heirs/locktimes with final dust marking (fixed AND percentage). Counts real_heirs (excl. w!ll3x3c") + valid_real_heirs (no "DUST" in HEIR_REAL_AMOUNT); raise HeirAmountIsDustException if real_heirs>0 and valid_real_heirs==0.
- Propagation: HeirAmountIsDustException is NOT WillExecutorFeeException -> skips that except in buildTransactions (heirs.py:646) -> reaches GUI clean, no misleading "error preparing transactions" log. (prepare_transactions guard was REMOVED/reverted to original.)
- GUI: dialogs.py task_phase1 ~803 added `except HeirAmountIsDustException` BEFORE generic `except Exception`: red msg_error "All heirs' shares are below the dust limit: the inheritance cannot be created. Increase the amounts or reduce the number of heirs." + return False,None (no sign/check, no empty will in list).
- Import: common.py ~65 now imports HeirAmountIsDustException from ...core.heirs (re-exported via `from .common import *`; no __all__).
- CRITICAL LEARNING: with FIXED amounts + LARGE balance, leftover funds are REDISTRIBUTED (normalize_perc real=True) so small fixed amounts get a VALID HEIR_REAL_AMOUNT (NOT dust). Real all-dust case = SMALL balance + PERCENTAGE heirs (matches user log 214/316/3 sat). heir list shape when redistributed: [addr, amt, lt, REAL_VALID, "DUST: x", dust_raw].
- 3 new tests (tests/test_core_heirs_extra.py): test_prepare_lists_all_dust_raises (800 sat, 40%/60% -> raises), test_prepare_lists_mixed_dust_continues (5000 fixed + 1% -> no raise), test_prepare_lists_multi_locktime_continues (1% @30d + 5000 @60d -> no raise).
- Version 0.4.6 -> 0.4.7. CHANGELOG #23. 258 tests pass (255+3), ruff no new errors.
- ZIP v0.4.7 delivered for testing. NO COMMIT until user confirms.

View File

@@ -853,3 +853,574 @@ commit).
**Outcome:** DONE (delivered as test ZIP v0.3.9, confirmed OK by the user before **Outcome:** DONE (delivered as test ZIP v0.3.9, confirmed OK by the user before
commit). commit).
---
## 16. v0.4.0 - Unified invalidate, clearer CHECK message, will-executor auto-select, SIMPLE/ADVANCED mode
**Date:** 2026-06-24
This release bundles five related improvements (Groups 1-3 of the user's
to-do list). Version bumped 0.3.9 -> 0.4.0.
### Group 1 - Invalidate / messages
**#01b - Clearer "Checking your will" message.**
The two misleading outcomes "Heir not found" and "New" were both replaced with a
single, accurate two-line message, because in practice that branch is most often
reached when the delivery date was anticipated, not when an heir is missing:
Found CHANGES to the DATE or the HEIRS,
a NEW WILL must be prepared.
- `bal/gui/qt/dialogs.py`: `task_phase1` - HeirNotFoundException branch and the
no-subtype fallback (previously "New").
- `bal/gui/qt/window.py`: `build_inheritance_transaction` - HeirNotFoundException
branch (kept consistent with the CHECK window).
- The other specific messages (Heirs changed / Will-Executor not present /
Will-Executor changed / Txfees changed) are unchanged.
**UNIFY invalidate procedure + #03 - Same behaviour for CHECK and WIZARD, and the
history label is now always written.**
When a will is expired (CHECK on an expired will, or an heir added to an expired
will), the plugin now ALWAYS:
1. shows a warning popup (no more "use Tools -> Invalidate" wording);
2. automatically opens Electrum's classic transaction window via
`BalWalletWindow.invalidate_will()` so the user signs and broadcasts the
invalidation - this path already sets the "BAL Invalidate transaction" history
label, which fixes #03 (the label was missing when invalidating from the
automatically-opened window).
- `bal/gui/qt/dialogs.py`: the first `WillExpiredException` handler in
`task_phase1` now returns the `"invalidate_classic"` signal (instead of routing
to the label-less automatic path); `on_success_phase1` shows the approved popup
text and opens the classic window with `QTimer.singleShot(0, ...)` AFTER closing
the wizard, so the transaction window stays in front.
### Group 2 - Will-executor auto-select (#02)
In the wizard's "Automatically download and select willexecutors" flow, the green
SELECTED tick now follows the green ping dot: only servers that actually answered
the ping (status == 200) are selected, and every non-responding server is
explicitly DESELECTED. This discards dead/slow servers at the source (no more
getting stuck broadcasting to them) and is re-evaluated on every download, so a
server that failed before but now answers is selected again.
- `bal/gui/qt/dialogs.py`: `ping_on_done` in `BalWizardWEDownloadWidget._on_next`
(robust `str(status) == "200"` comparison + safe `.get`).
### Group 3 - SIMPLE / ADVANCED mode (global)
A new global "USER TYPE" setting lets the user pick a simpler interface.
- New global config `USER_TYPE` (default `"basic"`) and helper
`BalPlugin.is_basic_mode()` in `bal/core/plugin_base.py`. Stored in Electrum's
global config, so it does not affect existing wallet files; an old wallet opens
with the global value and its owner can switch to ADVANCED at will.
- `bal/gui/qt/plugin.py`: new "USER TYPE" row in the settings dialog - a two-choice
combo [BASIC, ADVANCED] with a HelpButton, added at the top (row 0) and to the
"Reset setting" list (reset -> BASIC).
- In BASIC mode:
- the Raw/Date selector is hidden and every date field is forced to the calendar
("Date") editor, so the user never sees or uses RAW
(`bal/gui/qt/widgets.py`, `BalTimeEditWidget`);
- the whole "Check Alive" (threshold) row and its icon are hidden, while the
"Delivery time" (locktime) row stays visible
(`bal/gui/qt/widgets.py`, `WillSettingsWidget`);
- the "check alive" postpone behaviour is disabled: `init_class_variables`
skips raising `CheckAliveError`, so a passed check-alive date never forces a
postpone/rewrite (`bal/gui/qt/window.py`). The delivery time is unaffected.
### Verification
- `ruff`: no new errors in the changed lines (only the usual pre-existing
F401/F403/F405 star-import noise and one pre-existing F841).
- Full test suite: `239 passed`.
**Outcome:** DONE (delivered as test ZIP v0.4.0; commit only after the user
confirms the ZIP works).
---
## 17. v0.4.1 - Heir-change rebuild fix, all heirs in green, UI tidy-up, unified 20s deadline
**Date:** 2026-06-24
**Goal:** Address the user's v0.4.0 test feedback (points A-K), with the most
important items being two functional bugs surfaced via a log:
- **(E + F + K) Heir-change full rebuild (the core fix):** After deleting or
changing an heir, pressing CHECK said "Found CHANGES... a NEW WILL must be
prepared" but then "Signing: Nothing to do" / "Broadcasting: Nothing to do",
and the rebuilt transactions were never signed or broadcast. The log showed
that, right after `build_will()`, a second coherence check raised
`HeirNotFoundException(<heir name>)`, which was (1) shown in RED with the heir
name (bug E) and (2) made the dialog return `have_to_sign=False` (bugs F/K).
Root cause: `Will.update_will` reused the OLD (already signed/COMPLETE)
`WillItem` whenever a rebuilt transaction kept the same txid, copying only the
new heirs onto it - so a stale heir set survived and the item stayed COMPLETE.
Fix (user-approved "Option A"): a new helper `Will._same_heirs` compares the
real heirs (address/amount/locktime, ignoring the reserved `w!ll3x3c"`
will-executor pseudo-heirs); the old item is reused ONLY when the heirs are
identical, otherwise the freshly built (unsigned) item is kept so it is
correctly detected as needing signing and broadcasting. This also fixes K:
once the rebuilt tx is not COMPLETE, the existing auto-sign + auto-push path
(have_to_push when a will-executor is attached) fires automatically.
- **(E) Show all heirs in GREEN:** On a successful build, every heir is now
listed on its own line in green ("Ok" colour) in the Building Will window,
instead of a heir name ever appearing in red as exception text.
- **(A)** Delivery-time help popup: added a "(ONLY IN ADVANCED MODE)" line
before the Raw-suffix explanation.
- **(B)** Plugin settings: added a blank gap below the red warning, above the
first setting row.
- **(C)** Renamed the "USER TYPE" settings label to "User Type".
- **(D)** Renamed "Editable dates" to "Panel editable Date and Fee".
- **(G + K-label)** Moved the will-executor backup-tx checkbox up (now right
below "Panel editable Date and Fee", above "Number of reminders") and renamed
it from "No will-executor TX" to "Add transaction without willexecutor".
- **(H)** Wizard date-field layout: date rows are now a compact fixed width
(no longer the inflated 40-char minimum), the calendar button is left-aligned
and no longer stretches, and the mining-fee field is sized to ~5 characters.
ADVANCED mode (with the extra Check-Alive row) stays aligned.
- **(J)** Network waits unified into a single shared
`Willexecutors.NETWORK_DEADLINE = 20` constant (was 30s push/check and 45s
download); push/check/ping/download all derive from it.
**Files changed:**
- `bal/core/will.py` - new `_same_heirs` helper; `update_will` reuses old item
only when heirs are identical (Option A).
- `bal/gui/qt/dialogs.py` - list all heirs in green on successful build.
- `bal/gui/qt/widgets.py` - help-text line (A); compact, left-aligned wizard
date/fee layout (H).
- `bal/gui/qt/plugin.py` - settings labels/spacing/row order (B, C, D, G, K-label).
- `bal/core/willexecutors.py` - single `NETWORK_DEADLINE = 20` (J).
- `bal/gui/qt/window.py` - download deadline derives from `NETWORK_DEADLINE` (J).
- `tests/test_group_f_heir_change_rebuild.py` - new unit tests for `_same_heirs`.
- Version bumped 0.4.0 -> 0.4.1 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `ruff`: no new errors (only the pre-existing F401/F403/F405 star-import noise
and two pre-existing F841 at `will.py:151` and `dialogs.py:645`).
- Full test suite: `248 passed` (239 existing + 9 new Group F tests).
**Outcome:** PARTIAL (delivered as test ZIP v0.4.1). User testing showed that
the CORE bug (E/F/K) was STILL present: the `_same_heirs`/`update_will` fix was
misdirected - on a real heir change the rebuilt transactions get COMPLETELY NEW
txids, so `update_will`'s "reuse the old item when the txid matches" branch never
runs. The real cause was found and fixed in entry 18. The `_same_heirs` helper is
kept as a correct safety improvement. Items A, B, C, D, G, J, I were confirmed OK.
---
## 18. v0.4.2 - Real fix for the heir-change rebuild bug (E/F/K), wizard layout (H), tooltip
**Date:** 2026-06-24
**Goal:** Fix the bugs the user found while testing v0.4.1: after adding or
deleting an heir (or building from the wizard), the heir name was shown in RED
and the will was reported as "Nothing to do" (never signed/broadcast); the
green heir list disappeared; the wizard icons/fields were still misaligned and
the fee box was too small; and a tooltip needed rewording.
**Root cause of E/F/K (confirmed from the user's Electrum log):** In
`task_phase1` (dialogs.py), after `build_will()` rebuilds the whole will, a
SECOND `check_will()` re-validates it. When the heirs (or date) changed, this
re-validation legitimately raises a `NotCompleteWillException` subclass
(`HeirNotFoundException`) - the freshly rebuilt, still-unsigned transactions do
not yet "cover" the new heir set. That exception was caught by the GENERIC
`except Exception`, which printed the heir name in RED and returned
`have_to_sign=False`, so the rebuilt will was never signed ("Nothing to do").
**What changed:**
- **(E/F/K)** `bal/gui/qt/dialogs.py`: added a dedicated
`except NotCompleteWillException` handler BEFORE the generic `except Exception`
(and after the existing `WillExecutorNotPresent` / `WillExpiredException`
handlers, whose order matters). It treats the post-build re-validation failure
as what it really is - "the will was rebuilt and now needs signing" - instead
of an error. It shows the green "Ok" result and the full heir list, then falls
through to the existing `have_to_sign` detection, so the new (status "New")
transactions are correctly signed and (with a will-executor attached)
auto-broadcast. The heir-green-listing was extracted into a new
`_build_success_report()` helper, used on BOTH the clean and the rebuilt
paths, so the green heir list is always shown and the heir name never appears
in red again.
- **(H)** `bal/gui/qt/widgets.py`: reworked the wizard's vertical layout. Every
leading icon (delivery time, check-alive, calendar, fee) is forced to the same
fixed width and the composites are stacked left-aligned, so the icons line up
one under the other and every field starts at the same x just to their right.
The fee field is WIDENED (~8 chars) so the spin-box arrows no longer cover the
digits. The composites are kept INTACT (not split apart) because the
delivery-time / check-alive widgets hold two editors plus a runtime Raw/Date
selector in ADVANCED mode; splitting them would break that mode.
- **Tooltip** `bal/gui/qt/widgets.py`: the delivery-time icon tooltip now reads
exactly "Delivery Time, click for more information".
**Files changed:**
- `bal/gui/qt/dialogs.py` - `NotCompleteWillException` handler + `_build_success_report`.
- `bal/gui/qt/widgets.py` - wizard layout (H) and tooltip wording.
- Version bumped 0.4.1 -> 0.4.2 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `ruff`: no new errors (only pre-existing star-import noise and the two
pre-existing F841 at `widgets.py:563` and `dialogs.py:645`).
- Full test suite: `248 passed`.
**Outcome:** DONE (delivered as test ZIP v0.4.2; commit only after the user
confirms the ZIP works).
---
## 19. v0.4.3 - Sync delivery date after auto-anticipation; BASIC calendar reminders; sign-reason message
**Date:** 2026-06-24
**Goal:** Fix a follow-up bug reported by the owner after testing v0.4.2, add a
clearer message when signing is requested, and make the calendar export work in
BASIC mode.
**(1) Delivery date out of sync after an automatic anticipation (main bug).**
Root cause (confirmed from the code + the owner's report): when the will is
rebuilt while it still spends the same coins as a previous one (e.g. after
deleting an heir WITHOUT changing the date), the core engine AUTOMATICALLY
anticipates the transaction locktime by one day (Will.check_anticipate /
Util.anticipate_locktime) so the new transaction can be mined before the old
one. However the plugin's stored delivery date (WILL_SETTINGS["locktime"]) was
NOT updated, so on the next Check the plugin compared the stored date (original)
with the transaction locktime (original minus one day), mistook the automatic
anticipation for a user POSTPONE, and wrongly asked to invalidate the will.
Fix: after a (re)build, `BalBuildWillDialog._sync_locktime_to_built_txs` sets the
stored delivery date to the MINIMUM locktime among the valid built transactions
(via `Will.get_min_locktime`). The date is only ever moved EARLIER
(anticipation); a genuine user postpone is never overwritten. The update is
routed through `BalWindow.update_setting_widgets`, which stores the value,
persists it and refreshes the date widgets in every panel/wizard, so the visible
delivery date reflects the anticipated date and the calendar (.ics) export uses
it too (owner-confirmed behaviour; the minimum is used when several
transactions carry different locktimes).
**(2) Explain WHY signing is requested after an anticipation.**
When the date was auto-anticipated, the wizard now shows an orange note on the
"Building your will" row before the sign prompt: "The delivery date was
automatically moved one day earlier so the updated will can correctly replace
the previous one. Please sign (and broadcast) to confirm the change." (A
`_date_was_anticipated` flag set by the sync step drives this.)
**(3) Calendar reminders in BASIC mode.**
In BASIC mode the check-alive parameter is hidden and not managed by the user,
so spreading reminders over the check-alive period (the ADVANCED behaviour) is
meaningless and produced wrong/garbage dates. The calendar now uses three FIXED
reminders in BASIC - 30, 10 and 1 day before the inheritance delivery date -
dropping any offset that would fall in the past. ADVANCED mode is unchanged. The
logic is a pure helper `basic_reminder_offsets()` with unit tests.
**Files changed:**
- `bal/gui/qt/dialogs.py` - `_sync_locktime_to_built_txs` + `_date_was_anticipated`
flag + sign-reason note.
- `bal/gui/qt/widgets.py` - BASIC calendar reminders (`basic_reminder_offsets`,
`BASIC_REMINDER_OFFSETS`); the .ics export now uses the anticipated delivery
date.
- `tests/test_group_g_basic_calendar.py` - new unit tests for the BASIC
reminder offsets.
- Version bumped 0.4.2 -> 0.4.3 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `ruff`: no new errors (only pre-existing star-import noise and the two
pre-existing F841 at `widgets.py:563` and `dialogs.py:645`).
- Full test suite: `255 passed` (248 + 7 new BASIC-calendar tests).
**Outcome:** DONE (delivered as test ZIP v0.4.3; commit only after the user
confirms the ZIP works).
---
## 20. v0.4.4 - Wizard hints, BASIC/ADVANCED help text, Check tooltip, ADVANCED check-alive visibility, backup-tx default OFF
**Date:** 2026-06-24
**Goal:** Apply a batch of owner-requested UI/wording fixes and fix a visibility
bug for the Check-Alive field.
**What changed:**
- **(P1)** Plugin settings, "Number of reminders" help text: rewritten to
document BASIC and ADVANCED separately. BASIC: "Calendar reminder 30, 10 and
1 days before."; ADVANCED: the previous "spread across the check-alive period"
explanation (range 1 to 5, default 3).
- **(P2)** Build-your-will WIZARD only: added an explanatory label ABOVE the
date field - "Enter the date on which you want the inheritance (or backup) of
your Electrum wallet to take effect." - and a cautionary label BELOW the
miner-fee field - "Please note: Do not reduce the miner fees unless you know
what you're doing". These appear only in the wizard (vertical layout), not on
the WILL/HEIR toolbars.
- **(P3)** WILL tab: the Check button tooltip changed from "Check" to
"Check Inheritance" so the icon's purpose is clear.
- **(P4 - bug fix)** Check-Alive visibility on USER TYPE change: the WILL/HEIR
toolbar settings widgets are created once and reused for the whole session, so
switching from BASIC to ADVANCED previously did NOT re-show the Check-Alive
field there (it reappeared only in the freshly-created wizard). Added
`WillSettingsWidget.apply_user_type_visibility()`, called from
`BalWindow.update_all()` (which the USER TYPE combo triggers), so the
Check-Alive field is shown/hidden immediately on the existing WILL and HEIR
tabs too, without restarting Electrum.
- **(P5)** "Add transaction without willexecutor" now defaults to OFF
(`NO_WILLEXECUTOR` default False). A fresh wallet therefore does NOT create the
extra no-will-executor backup transaction unless the user enables it from the
wizard. The value is persisted in Electrum's configuration (as before), so the
plugin always follows the saved choice; the new default only applies when no
value has been stored yet.
**Files changed:**
- `bal/gui/qt/plugin.py` - reminders help text (P1).
- `bal/gui/qt/widgets.py` - wizard date hint + miner-fee note (P2);
`apply_user_type_visibility` (P4).
- `bal/gui/qt/lists.py` - Check tooltip -> "Check Inheritance" (P3).
- `bal/gui/qt/window.py` - call `apply_user_type_visibility` from `update_all` (P4).
- `bal/core/plugin_base.py` - `NO_WILLEXECUTOR` default -> False (P5).
- Version bumped 0.4.3 -> 0.4.4 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `ruff`: no new errors (only pre-existing star-import noise and pre-existing
F841 at `widgets.py:591`, `lists.py:185` and `lists.py:272`).
- Full test suite: `255 passed`.
**Outcome:** DONE (delivered as test ZIP v0.4.4; commit only after the user
confirms the ZIP works).
---
## 21. v0.4.5 - Fix invalidation loop (10s pause + stop-on-persistent-postpone), add "BAL Invalidate transaction" label on the automatic path, fix wizard text truncation
**Date:** 2026-06-24
**Reported issues (after testing v0.4.4):**
- **Issue 1 (wizard text truncated):** the explanatory hint above the delivery
date and the miner-fee note below the fee field were displayed cut in half.
- **Issue 2 (invalidation loop):** when the user postpones the delivery date,
the "Invalidate your old will" window appears; after signing, an IDENTICAL
invalidation window immediately appears again (endless loop) while Electrum
is still broadcasting the transaction. In addition, the
"BAL Invalidate transaction" label did not appear in Electrum's on-chain
history for this automatic ("postpone") path.
**Root causes:**
- Issue 1: the wizard QLabels had `setWordWrap(True)` but were added to the
vertical layout with `AlignLeft`, so each label took its narrow `sizeHint`
width. Word-wrap then computed line breaks against an almost-zero width and
the text appeared truncated.
- Issue 2a (loop): after broadcasting the automatic invalidation,
`on_success_invalidate` re-ran phase 1 immediately. Electrum had not yet seen
the invalidation transaction, so phase 1 still detected a postpone and the
wizard re-prompted to invalidate - forever.
- Issue 2b (missing label): the automatic broadcast in
`loop_broadcast_invalidating` did not set any history label (unlike the
Tools -> Invalidate menu path, which does).
**What changed (user-approved solution):**
- `bal/gui/qt/dialogs.py`
- `loop_broadcast_invalidating`: set the `"BAL Invalidate transaction"`
history label (fixes Issue 2b), matching the Tools -> Invalidate menu.
IMPORTANT follow-up fix: the first attempt did not work because it took the
txid from `Network.broadcast_transaction()`'s return value - but that method
is declared `-> None` and ALWAYS returns None, so the `set_label()` call
sat in an `else` branch that was never reached. The label is now taken from
`tx.txid()` (the transaction is already signed and complete here, so this is
the stable, correct id - exactly what the working Tools -> Invalidate path
uses) and is set BEFORE broadcasting (set_label is local-only, no network).
- `invalidate_task`: the post-broadcast pause is now 10 seconds (was 5) so
Electrum has time to register the new transaction before phase 1 re-runs;
a new `self._invalidation_broadcast` flag is set after the broadcast.
- `on_success_phase1` (the `have_to_sign is None` branch): if
`_invalidation_broadcast` is set and a postpone is STILL detected, STOP with
a clear message ("Your old will has been invalidated and the transaction was
broadcast... please wait until it is confirmed, then press Check again")
instead of re-prompting to invalidate (fixes Issue 2a - no more loop).
- `__init__`: initialise `self._invalidation_broadcast = False`.
- `bal/gui/qt/widgets.py`
- Wizard branch: give the two explanatory QLabels (delivery-date hint and
miner-fee note) a `setMinimumWidth(30 * char_width_in_lineedit())` so
word-wrap uses the full dialog width and the whole text is visible
(fixes Issue 1).
- Version bumped 0.4.4 -> 0.4.5 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `py_compile`: `dialogs.py` and `widgets.py` compile OK.
- Full test suite: `255 passed`.
- `ruff`: no new errors (only pre-existing star-import F401/F403/F405 noise and
the pre-existing F841 `e` warnings).
- Headless wizard label check: with the 270px minimum width, both labels wrap
to multiple lines (3 lines and 2 lines) instead of being truncated.
**Outcome:** DONE (delivered as test ZIP v0.4.5; commit only after the user
confirms the ZIP works).
---
## 22. v0.4.6 - DUST one-line-per-heir, heirs on one line, scrollable report, wizard final check, wizard text truncation fix, anticipated-date notice styling
**Date:** 2026-06-24
**Reported issues (after testing v0.4.5):**
- **Issue 1 (allegato13 - DUST):** when the wallet balance is below the dust
limit the inheritance is not feasible. The dialog printed the "is DUST"
exclusion ONCE PER (will-executor x heir): with 20 will-executors and 10
heirs that is 200 identical rows. There should be one row PER HEIR.
- **Issue 2 (allegato18 - heirs):** heirs were listed one per line, wasting
vertical space. They should be on a single line: "Heirs: a, b, c".
- **Issue 3 (allegato14 - overflow):** with many will-executors the "Building
Will" window kept growing taller for every line until it ran off-screen and
the bottom buttons became unreachable. It needs a scrollable area.
- **Issue 4 (allegato15 - wizard final check, case B):** the wizard did not run
the final will-executor verification, so the user always had to press "Check"
manually afterwards.
- **Issue 5 (allegato16 - wizard text truncated):** the wizard hint and fee
note were still cut in half.
- **Issue 6 (allegato17 - notice styling):** the yellow "delivery date was
moved" notice should be black bold and split onto two lines.
**Root causes:**
- Issue 1: a double loop over every valid will AND every heir printed the dust
row N_executors x N_heirs times.
- Issue 4: the wizard's `on_next_we` only called `build_will_task()` and, unlike
the "Check" button (`lists.py:check()`), never called
`check_transactions()`.
- Issue 5: the two QLabels were added to the vertical layout WITH an
`alignment=AlignLeft` flag. A widget added with an alignment flag is NOT
stretched to the layout width, so the word-wrapped label used a narrow
sizeHint width and the reserved height was too small, cutting the text.
`setMinimumWidth` alone could not fix it because the alignment flag still
blocked horizontal stretch.
**What changed:**
- `bal/gui/qt/dialogs.py`
- `task_phase1` (DUST report): collect dust heirs in a de-duplicated dict and
print ONE row per heir, without the will-executor reference (Issue 1).
- `_build_success_report`: list all heirs on a SINGLE green/bold line
("Heirs: a, b, c") instead of one row each (Issue 2).
- `__init__` + `msg_update`: wrap the report label in a `QScrollArea` with a
capped maximum height (400px) and auto-scroll to the bottom, so the dialog
no longer grows off-screen and the buttons stay reachable (Issue 3).
- `BalWizardDialog.on_next_we`: after building, run the SAME final
`check_transactions()` as the Check button (`Will.needs_server_check`),
so the wizard performs the will-executor verification automatically
(Issue 4).
- `on_success_phase1`: the anticipated-date notice is now black bold and split
onto two lines after "...previous one." (Issue 6).
- `bal/gui/qt/widgets.py`
- Wizard branch: add the two explanatory QLabels WITHOUT an alignment flag and
with an Expanding/Minimum size policy (plus a minimum width on the widget),
so they stretch to the full width and word-wrap correctly instead of being
truncated (Issue 5).
- Version bumped 0.4.5 -> 0.4.6 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `py_compile`: `dialogs.py` and `widgets.py` compile OK.
- Full test suite: `255 passed`.
- `ruff`: no new errors (only pre-existing star-import noise and the
pre-existing F841 `e` warnings).
- Headless wizard label check (real wizard layout, 780px dialog): both labels
expand to the full width (740px) and the full text fits - NOT truncated.
**Outcome:** DONE (delivered as test ZIP v0.4.6; commit only after the user
confirms the ZIP works).
---
## 23. v0.4.7 - Report area opens 500px tall, heirs back to one-per-line, explicit wizard line breaks, ALL-DUST guard (block only when EVERY heir is dust)
**Date:** 2026-06-24
**Goal (4 owner-approved changes from testing v0.4.6):**
1. (allegato1) The scrollable "Building Will" report opened far too short
(~140px). Open it already 500px tall, growing up to 700px before the
scrollbar takes over.
2. Revert the v0.4.6 "all heirs on one line" form back to ONE heir per line
(green, bold). Heir names can be long and, now that the report scrolls,
there is no need to compress them.
3. (allegato2) Add an explicit line break in two wizard texts at the exact
spots the owner marked: after "(or backup)" in the date hint and after
"miner fees" in the fee note.
4. (LOG analysis) When EVERY heir's share is below the dust limit the will was
still built, signed, checked and listed - an "empty" inheritance that pays
nobody. Block it with a clear message, but ONLY when ALL heirs are dust; a
mix of dust + valid heirs must keep building normally.
**What changed:**
- `bal/gui/qt/dialogs.py`
- `BalBuildWillDialog.__init__`: report `QScrollArea` now
`setMinimumHeight(500)` / `setMaximumHeight(700)` (change 1).
- `_build_success_report`: list each heir on its own line again, green +
bold, de-duplicated and skipping the internal will-executor pseudo-heirs
(change 2, revert of v0.4.6).
- `task_phase1`: add a dedicated `except HeirAmountIsDustException` handler
BEFORE the generic `except Exception`, showing a clear RED message
("All heirs' shares are below the dust limit: the inheritance cannot be
created. Increase the amounts or reduce the number of heirs.") and stopping
without signing/checking, so no empty will is created (change 4).
- `bal/gui/qt/widgets.py`
- Wizard date hint: explicit `\n` after "(or backup)".
- Wizard fee note: explicit `\n` after "miner fees" (change 3).
- `bal/core/heirs.py`
- `prepare_lists`: NEW all-dust guard added at the END of the function, where
the `locktimes` dict already contains EVERY heir of EVERY locktime with the
final dust marking. It counts the real heirs (excluding the `w!ll3x3c"`
will-executor pseudo-heirs) and how many have a valid, non-dust amount; if
there are real heirs but none is payable it raises
`HeirAmountIsDustException` (change 4).
- WHY here and NOT in `prepare_transactions`: `prepare_transactions` only
ever processes the single lowest locktime, so a guard there would wrongly
block a will whose later locktimes still have valid heirs (false positive).
`prepare_lists` is the only place that sees all heirs/locktimes AND the
final dust state of both fixed and percentage heirs.
- The `HeirAmountIsDustException` raised here propagates cleanly: it is not a
`WillExecutorFeeException`, so it skips that handler in `buildTransactions`
and reaches the GUI without the misleading "error preparing transactions"
log.
- `bal/gui/qt/common.py`
- Import `HeirAmountIsDustException` from `...core.heirs` so it is available
to `dialogs.py` via `from .common import *`.
- `tests/test_core_heirs_extra.py`
- Add 3 tests pinning the dust logic:
`test_prepare_lists_all_dust_raises` (tiny balance + percentages -> raises),
`test_prepare_lists_mixed_dust_continues` (dust + valid -> no raise),
`test_prepare_lists_multi_locktime_continues` (dust on early date, valid on
later date -> no raise; guards against the false positive).
- Version bumped 0.4.6 -> 0.4.7 (`plugin_base.py`, `__init__.py`, `VERSION`,
`manifest.json`).
**Verification:**
- `py_compile`: `heirs.py`, `common.py`, `dialogs.py` and the test file compile OK.
- Full test suite: `258 passed` (255 previous + 3 new dust tests).
- `ruff`: no new errors (only pre-existing star-import noise and the
pre-existing F841 `e` warnings).
- Manual dust trace (real `prepare_lists`, mocked wallet): all-dust raises;
mixed and multi-locktime continue and keep the valid heir.
**Outcome:** DONE (delivered as test ZIP v0.4.7; commit only after the user
confirms the ZIP works).

View File

@@ -1,221 +1,232 @@
# HANDOFF — BAL Electrum Plugin (Bitcoin After Life) # HANDOFF — BAL (Bitcoin After Life) Electrum plugin
> **Purpose of this file:** allow ANY new chat / AI model to resume this project > Purpose: let ANY future AI assistant (Claude or another model, more advanced
> WITHOUT losing context. If you are a new assistant, READ THIS FILE FIRST, > or cheaper) resume work on this project with full context, without having to
> then read `CHANGELOG.md` and `.agent_memory_tasks.md`. > re-discover the codebase. Read this file FIRST, then `CHANGELOG.md` and
> > `.agent_memory_tasks.md`.
> **Chat language is Italian, but ALL output (code, comments, UI text, docs,
> CHANGELOG, commit messages) MUST be in ENGLISH.** The user is NOT a programmer.
--- ---
## 1. MANDATORY STANDING RULES (apply to EVERY task, never skip) ## 0. TL;DR — what this project is
- **R1 — LANGUAGE:** Italian is ONLY for chatting. ALL deliverables in ENGLISH - **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
(code, docstrings, comments, UI strings, docs, CHANGELOG, commit messages). **Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**).
- **R2 — DOCUMENTED CODE:** every method/class needs a docstring + explanatory - **Form:** external **ZIP plugin** (not bundled in Electrum). The user
comments. When a non-obvious design choice is made, explain WHY in a comment. installs the ZIP from Electrum's plugin manager.
- **R3 — NEVER INVENT:** if anything is missing or unclear, STOP and ask the user - **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
clear, simple questions (he is not a programmer). Be "100% sure" before acting. Bitcoin transactions that pay one or more **heirs** after a chosen **date**
- **R4 — HUMAN CHECKPOINT:** before writing/modifying code, show the PLAN and WAIT (a future UNIX-timestamp `nLockTime`). Optional **will-executors** (remote
for the user's explicit "OK". services) can be paid a fee to broadcast the inheritance when due. The owner
- **METHOD per task:** DISCOVER → PLAN (wait OK) → EXECUTE → VERIFY → ITERATE periodically proves they are alive ("check-alive"); if the deadline passes,
(max 8 attempts, then declare "UNRESOLVED"). the inheritance becomes spendable.
- **LOG:** a single `CHANGELOG.md` in English, one numbered entry per task. - **Current version:** see `bal/VERSION` (last shipped: **0.4.7**).
- **ZIP-FIRST:** always deliver a test ZIP for the user to try BEFORE committing
plugin code. Commit ONLY after explicit user confirmation.
- Always run `ruff` + the official test suite before committing/reporting/zipping.
--- ---
## 2. PROJECT OVERVIEW ## 1. MANDATORY working rules (the owner set these — always follow them)
- Electrum **4.7.2** Qt (PyQt6) inheritance plugin. These are non-negotiable. They come from the owner directly.
- External zip plugin id: `electrum_external_plugins.bal`.
- **zipimport caches the plugin → the user MUST fully restart Electrum after
installing a new ZIP** (always remind him).
- Repo: `Bitcoin-after-life/test` (GitHub). Working branch: `genspark_ai_developer`.
- Main code lives under `bal/`. Tests under `tests/`. Electrum source vendored in
`electrum-src/` (read-only reference).
### Version files (keep ALL FOUR in sync on every release) - **R1 — LANGUAGE.** The CHAT language with the owner is **Italian**. But ALL
- `bal/manifest.json``"version"` *output* — source code, comments, docstrings, UI strings, docs, `CHANGELOG.md`,
- `bal/__init__.py``__version__` commit messages, this handoff — must be in **ENGLISH**.
- `bal/core/plugin_base.py``__version__ = "..." # AUTOMATICALLY GENERATED DO NOT EDIT` - **R2 — DOCUMENTED CODE.** Every method/class gets a docstring + explanatory
- `bal/VERSION` comments. Always explain *WHY* for any non-obvious decision.
- **Current released version: 0.3.9** - **R3 — NEVER INVENT.** If something is missing or unclear, STOP and ask the
owner clear, simple questions. **The owner is NOT a programmer** — explain in
plain language, avoid jargon. Be "100% sure" before acting.
- **R4 — HUMAN CHECKPOINT.** Before writing/modifying code, present the PLAN
and WAIT for an explicit "OK" from the owner.
- **METHOD:** DISCOVER → PLAN (wait for OK) → EXECUTE → VERIFY → ITERATE
(max ~8 attempts per problem, then step back and ask).
- **LOG:** keep a single `CHANGELOG.md`, in English, **one numbered entry per
task** (newest entry appended at the END of the file).
- **ZIP-FIRST.** Deliver a test ZIP and let the owner test it BEFORE committing.
**Commit ONLY after the owner explicitly confirms the ZIP works.**
- **ALWAYS** run `ruff` + the official test suite before committing / reporting
/ zipping.
- **CREDIT-SAVING (important).** The owner is low on funds. Minimize token /
credit usage: report brief summaries (do NOT paste whole modified code
blocks back), and batch work into a single ZIP/test cycle where possible.
--- ---
## 3. BUILD / TEST / RELEASE COMMANDS ## 2. Repository layout (what lives where)
### Build the ZIP (clear caches first) ```
```bash bal/ <- the plugin package (this is what ships in the ZIP)
cd /home/user/webapp __init__.py <- __version__ (one of 4 version files)
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete VERSION <- plain-text version (one of 4 version files)
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # builds 37 files manifest.json <- plugin manifest, "version" field (one of 4)
core/
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4)
heirs.py <- HEIRS + transaction building (prepare_lists,
prepare_transactions, buildTransactions). CORE LOGIC.
will.py <- Will/WillItem, validation (check_amounts, check_will),
exceptions (AmountException, WillExpiredException, ...).
willexecutors.py <- remote will-executor services handling.
util.py <- locktime parsing/most helpers (timestamps only).
gui/qt/
common.py <- shared imports; every gui module does
`from .common import *`. Add new shared imports HERE.
dialogs.py <- the big build/sign/broadcast dialog
(BalBuildWillDialog, task_phase1/2), wizard glue.
widgets.py <- WillSettingsWidget + wizard widgets/labels.
window.py <- BalWalletWindow (build_will, check_will, get_transactions).
lists.py, calendar.py, theme.py, window_utils.py, ...
tests/ <- pytest suite (see run command below).
electrum-src/ <- a copy of Electrum source, used ONLY for tests
(PYTHONPATH=electrum-src). NOT shipped in the ZIP.
build_zip.py <- builds the shippable ZIP (37 files).
CHANGELOG.md <- numbered task log (English).
.agent_memory_tasks.md <- terse internal memory notes per task batch.
HANDOFF.md <- this file.
``` ```
### Run the full test suite (must stay GREEN: 239 passed) ---
## 3. How to build, test and lint
Run everything from `/home/user/webapp`.
**Full test suite (expected: 258 passed as of v0.4.7):**
```bash ```bash
cd /home/user/webapp
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \ QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.py tests/test_gui_*.py \ tests/test_core_*.py tests/test_gui_*.py \
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \ tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \ tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_giovanna7.py -q tests/test_group_d_alarms.py tests/test_group_e_mock_giovanna7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py -q
``` ```
### Ruff (only PRE-EXISTING noise is acceptable) **Lint (only NEW errors matter; ignore pre-existing noise):**
```bash ```bash
cd /home/user/webapp && ruff check bal/<changed files> ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
| grep -vE "F401|F403|F405|F841"
``` ```
Pre-existing warnings that are NOT your fault and can be ignored: Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via
- `F401/F403/F405` star-import noise (`from .common import *`). `from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
- `F841` at: `will.py:151`, `dialogs.py:615` (`except NoHeirsException as e`), Do NOT "fix" these unless asked — they are intentional / out of scope.
`lists.py:185`, `lists.py:272`.
- `E501` long lines in several pre-existing spots.
### Git / PR / Release workflow **Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
- `setup_github_environment` first. If `git push` fails with ```bash
"Invalid username or token", CALL `setup_github_environment` AGAIN, then retry. find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
- Token for API calls: python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files
`TOKEN=$(sed -n 's#https://\([^:]*\):\([^@]*\)@.*#\2#p' ~/.git-credentials | head -1)`
- Repo for API: `Bitcoin-after-life/test`.
- Releases published so far: v0.3.6, v0.3.7, v0.3.8, **v0.3.9 (latest)**.
- Attach the ZIP as a release asset via the uploads API.
---
## 4. CURRENT STATE (as of v0.3.9, COMMITTED + RELEASED)
v0.3.9 is merged to `main` (PR #11) and released with the ZIP attached.
sha256 of the released ZIP: `cd52b6f5e6276fb707ea4bf477a00bd0469dfd88960282fb74c219ee0f5f4292`.
What shipped in v0.3.9 (TASK A/B/C/D + regression fix A3):
- **A** clearer "Will expired" message: shortened will id (8+8 chars via
`Will._short_will_id`) + readable UTC date (`Will._format_locktime`) instead of
raw UNIX timestamp; shown ORANGE (warning) not RED (error) in the wizard.
- **A2** message split on two lines via `<br>` (rendered as HTML by `msg_warning`).
- **B** History labels (text only): inheritance tx → `BAL Inheritance transaction`;
invalidate tx → `BAL Invalidate transaction`. Colours = Electrum defaults
(Electrum colours outgoing tx descriptions red by itself, history_list.py:193-196).
- **C** "No will-executor TX" checkbox in plugin Settings (`plugin.py`), bound to
existing `NO_WILLEXECUTOR` config (default ON, line plugin_base.py:193), with help
text "Create a will that does not require a Will-executor; it can be saved, for
example, on a USB stick, and a copy can be given to the heirs." Included in reset.
- **D** wizard button "Create your will" → "Build Your Will" (lists.py:473).
- **A3 regression fix:** adding an heir to an expired will via the wizard no longer
failed to invalidate. Implemented a `"invalidate_classic"` signal returned from
`task_phase1` and handled in `on_success_phase1` (dialogs.py) that closes the
wizard and shows a popup telling the user to use `Tools → Invalidate`.
**NOTE: this popup text is about to be CHANGED — see the pending task below.**
---
## 5. PENDING TASK (NOT STARTED) — UNIFY THE INVALIDATE PROCEDURE
### Problem the user reported
There are currently TWO different "invalidate" procedures, and the user wants ONE
identical behaviour for BOTH the **CHECK button** and the **WIZARD**.
- **PROCEDURE 1 "classic/manual"** = `window.py::invalidate_will` (line ~695):
waiting dialog → "please sign and broadcast" popup → CLASSIC Electrum tx window
(Sign/Broadcast buttons) → **SETS** history label "BAL Invalidate transaction"
(line ~704). Used by: Tools→Invalidate menu (lists.py:468→571), a dialog button
(dialogs.py:1356), the on-close/postpone paths (window.py:539,576,614).
**This window opens correctly IN FRONT** (user confirmed) because nothing else
is closing at the same time.
- **PROCEDURE 2 "automatic"** = `dialogs.py::invalidate_task` (line ~902):
password prompt inside the wizard → sign + auto-broadcast
(`loop_broadcast_invalidating`, line ~729) → **does NOT set the history label**.
Used by the CHECK button and the FIRST `WillExpiredException` handler in
`task_phase1` (dialogs.py:594 → `return None, Will.invalidate_will` at ~598,
which makes `on_success_phase1` see `have_to_sign is None` → password prompt).
### CHECK button flow (important)
`lists.py:545 check()``BalBuildWillDialog(...).build_will_task()`
`task_phase1``on_success_phase1`. **It is the SAME engine as the wizard.**
### FINAL REQUIREMENT (user-approved, OPTION A refined)
Make CHECK and WIZARD behave IDENTICALLY for an expired will:
1. First show a **WARNING popup** (REMOVE the old "use the top-right menu
Tools → Invalidate" wording).
2. Then **AUTOMATICALLY open the CLASSIC Electrum sign window** (PROCEDURE 1,
`window.py::invalidate_will`) so the label is set and the user can Sign + Broadcast.
3. Sequence: warning popup → user clicks OK → classic sign window opens BY ITSELF,
IN FRONT.
### APPROVED WARNING POPUP TEXT (verbatim, English per R1)
``` ```
Your will has expired and must be invalidated before it can be rebuilt.
A transaction window will now open: **Bump version — there are FOUR files, keep them in sync:**
please SIGN and then BROADCAST it to invalidate your old will. ```
After the invalidation is confirmed, press the Check button to finish the will. bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT
bal/__init__.py -> __version__ = "X.Y.Z"
bal/VERSION -> X.Y.Z
bal/manifest.json -> "version": "X.Y.Z",
``` ```
(The user wrote "SIGN and then BROADCAST" with a double space — normalize to a
single space unless he objects.)
### Implementation approach (agreed in principle; still needs final PLAN + OK) **IMPORTANT for the owner when testing:** after installing a ZIP, the owner
- Route the expired cases (FIRST handler at ~594, the `invalidate_classic` block, must **fully restart Electrum** (not just reload the plugin) — Electrum's
and therefore the CHECK button) through ONE shared helper that: `zipimport` caches modules, so a partial reload runs stale code.
(a) closes the CHECK/wizard dialog FIRST,
(b) shows the warning popup,
(c) then calls `self.bal_window.invalidate_will()` (PROCEDURE 1) LAST, so the
classic window is the last thing opened and stays in front.
- Drop the use of PROCEDURE 2 (`invalidate_task`) for the expired case.
- **KNOWN RISK / why this is delicate:** earlier attempts to auto-open the classic
window *while the wizard was closing* put it BEHIND the main wallet window on the
user's machine (Windows focus/stacking). The fix is to make sure NOTHING closes
AFTER the classic window opens (close the dialog first, open the tx window last).
`Tools → Invalidate` works perfectly precisely because no other window is closing.
- The user has hinted he may add MORE requirements before this is implemented, so
CONFIRM the full scope before coding.
### Status: WAITING. Do NOT code yet. Build full PLAN → wait OK (R4) → zip-first.
--- ---
## 6. KEY FILE / LINE REFERENCES (verify line numbers, they drift) ## 4. Key technical knowledge (hard-won — saves you hours)
- `bal/core/will.py` - **Locktimes are UNIX timestamps only.** Block-height locktimes were removed
- `check_will()` order (line ~561): `check_invalidated``check_will_expired` (CHANGELOG #1). Ordering/expiry compare timestamps.
(raises `WillExpiredException`) → `search_rai` (raises `HeirNotFoundException`). - **`heirs.py` data shape.** An heir is a list indexed by constants
- `invalidate_will()` static (line ~394) builds the invalidation PartialTransaction. (`heirs.py` top): `HEIR_ADDRESS=0`, `HEIR_AMOUNT=1` (sats or `"<n>%"`),
- `_short_will_id` / `_format_locktime` helpers + the expired message (with `<br>`). `HEIR_LOCKTIME=2`, `HEIR_REAL_AMOUNT=3` (resolved sats, or the string
- `bal/gui/qt/dialogs.py` `"DUST: <n>"` when below the dust limit), `HEIR_DUST_AMOUNT=4` (raw dust sats).
- `BalBuildWillDialog` is the CHECK + wizard engine. - **Will-executor pseudo-heirs.** Internally, each selected will-executor is
- `build_will_task()` (~530) starts `task_phase1`. injected as a fake "heir" whose NAME starts with the reserved marker
- `task_phase1()` (~542): first `check_will()`; first `WillExpiredException` `w!ll3x3c"` (i.e. `'w!ll3x3c"' + url + '"' + str(locktime)`). Its amount is
handler (~594) → `return None, Will.invalidate_will(...)`; `NotCompleteWill`/ the executor `base_fee` (always non-dust). When you count/iterate "real"
`HeirNotFound``have_to_build`; inner `check_will()`; inner `WillExpiredException` heirs you MUST skip names starting with `w!ll3x3c"`.
(~659) → currently returns `"invalidate_classic", None`. - **Transaction-building pipeline:**
- `on_success_phase1()` (~924): unpacks `(have_to_sign, tx)`. If `window.build_will()``Heirs.get_transactions()` (recursive over locktimes)
`have_to_sign == "invalidate_classic"` → shows popup. If `have_to_sign is None` `Heirs.buildTransactions()``Heirs.prepare_lists()` (builds the
→ password prompt "Invalidate your old will" → `invalidate_task` (PROCEDURE 2). `locktimes` dict for ALL future locktimes, resolves amounts, marks dust)
- `invalidate_task()` (~902) + `loop_broadcast_invalidating()` (~729): PROCEDURE 2. and `prepare_transactions()` (builds ONE tx for the LOWEST locktime only;
- `QTimer` is available via `from .common import *` (defined in common.py:53). the recursion handles the others via leftover `available_utxos`).
- `bal/gui/qt/window.py` - **DUST logic (v0.4.7 — verify before touching):**
- `invalidate_will()` (~695): PROCEDURE 1 (the "good" one). Sets label at ~704. - The "all heirs are dust" guard lives at the END of `prepare_lists`
- `show_transaction_real()` (~656) uses `show_on_top(d, modal_to_window=False)`. (NOT in `prepare_transactions`). Reason: `prepare_transactions` only sees
- on-close/postpone expired handling at ~539, ~576, ~614. the single lowest locktime, so a guard there would FALSE-POSITIVE block a
- `bal/gui/qt/lists.py` will whose later locktimes still have valid heirs. `prepare_lists` is the
- `check()` (~545): the CHECK button. `invalidate_will()` (~571). Menu actions only place that sees ALL heirs across ALL locktimes with their final dust
"Check"/"Invalidate" at ~467/468. Wizard button "Build Your Will" at ~473. state (fixed AND percentage).
- `bal/gui/qt/plugin.py`: settings dialog; "No will-executor TX" checkbox + reset. - Guard: count real heirs (skip `w!ll3x3c"`); if there are real heirs but
- `bal/gui/qt/window_utils.py`: `show_on_top` (~100), `bring_to_front` (~52), NONE has a valid (non-`"DUST"`) `HEIR_REAL_AMOUNT`, raise
`show_modal` (~86). `HeirAmountIsDustException` (defined in `heirs.py`). A mix of dust + valid
- `bal/gui/qt/common.py`: `add_widget` helper (~98); imports `QTimer`, `Qt`, etc. heirs keeps building normally.
- **Critical nuance:** with FIXED amounts and a LARGE balance, leftover funds
are REDISTRIBUTED (`normalize_perc(..., real=True)`), so small fixed
amounts end up with a VALID `HEIR_REAL_AMOUNT` (not dust). The real
all-dust case is **small balance + percentage heirs** (matches the owner's
log: shares of 214 / 316 / 3 sat). Tests reproduce this with
`prepare_lists(800, 100, wallet)` and `"40%"/"60%"` heirs.
- The exception is NOT a `WillExecutorFeeException`, so it skips that handler
in `buildTransactions` and propagates cleanly to the GUI.
- GUI: `dialogs.py task_phase1` has a dedicated `except
HeirAmountIsDustException` BEFORE the generic `except Exception`. It shows a
RED message and stops (`return False, None`) — no signing/checking, no
empty will in the list. `HeirAmountIsDustException` is imported in
`common.py` and re-exported via `from .common import *`.
- **`broadcast_transaction` returns `None`** (Electrum `network.py`). To get a
txid, use `tx.txid()` — do NOT rely on the broadcast return value
(this was the root cause of the missing "BAL Invalidate transaction" label,
CHANGELOG #21 / v0.4.5).
- **Qt label truncation gotcha (CHANGELOG #22).** A `QLabel` added with
`alignment=Qt.AlignmentFlag.AlignLeft` is NOT stretched by Qt, so word-wrap
computes on a narrow sizeHint and the text gets truncated. Fix: drop the
alignment flag, add `setSizePolicy(Expanding, Minimum)` + `setMinimumWidth`.
With `setWordWrap(True)`, an explicit `\n` in the text forces a line break.
- **`BalBuildWillDialog` report area.** Messages are accumulated as HTML in
`self.labels` and joined by `msg_update` (`"<br><br>".join(...)`, `\n`→`<br>`).
The report is inside a `QScrollArea` (v0.4.7: `setMinimumHeight(500)`,
`setMaximumHeight(700)`); the Close button sits BELOW the scroll area so it
stays reachable.
--- ---
## 7. HOW TO RESUME IN A NEW CHAT (any model) ## 5. Git / delivery workflow
Tell the new assistant: - **Branch:** work on `genspark_ai_developer`. Open PRs into `main`.
> "Read `/home/user/webapp/HANDOFF.md`, then `CHANGELOG.md` and - **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
> `.agent_memory_tasks.md`. Follow rules R1R4 and zip-first. The next task is the works, THEN commit. (This differs from "commit after every change"; the owner
> 'unify invalidate procedure' task in HANDOFF.md section 5 — present the PLAN and explicitly prefers ZIP-first because they manually test each build.)
> wait for my OK before coding." - Before opening/updating a PR: `git fetch origin main`, rebase, resolve
conflicts preferring remote `main` unless a local change is essential,
squash local commits into ONE comprehensive commit, push (force if needed),
then create/update the PR and SHARE the PR URL with the owner.
- The previous PR for this line of work is **PR #13** on the repo.
- Deliverable ZIPs are uploaded with the file-wrapper tool and the URL is given
to the owner. (Latest: v0.4.7.)
Everything needed (rules, state, pending task, build/test commands, file map) is in ---
this file. The real work is safe in Git (PR #11, release v0.3.9) and in CHANGELOG.md.
## 6. Version history (short — full detail in CHANGELOG.md)
- **v0.4.5** — fix invalidation loop; add "BAL Invalidate transaction" label on
the automatic path (root cause: `broadcast_transaction` returns None → use
`tx.txid()`); fix wizard text truncation.
- **v0.4.6** — DUST one-line-per-heir report; heirs on one line; scrollable
report area; wizard final check (`on_next_we` now calls
`check_transactions`); wizard truncation fix (remove AlignLeft); anticipated-
date notice styling.
- **v0.4.7** — report area opens 500px tall (max 700); heirs reverted to ONE
per line (green/bold); explicit `\n` line breaks in two wizard texts
(after "(or backup)" and after "miner fees"); **ALL-DUST guard** in
`prepare_lists` that blocks (clear RED message) only when EVERY heir is dust;
3 new tests pinning the dust behaviour. 258 tests pass.
---
## 7. How to resume (checklist for the next AI)
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
2. Confirm the environment: `git status`, current branch, `bal/VERSION`.
3. Run the full test suite (Section 3) — expect all green (258 as of v0.4.7).
4. Talk to the owner in **Italian**, write everything else in **English**.
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
build a ZIP, let the owner test, and only commit after explicit confirmation.
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.

View File

@@ -1 +1 @@
0.3.9 0.4.7

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.9" __version__ = "0.4.7"

View File

@@ -554,6 +554,43 @@ class Heirs(dict, Logger):
locktimes[locktime] = {key: value} locktimes[locktime] = {key: value}
else: else:
locktimes[locktime][key] = value locktimes[locktime][key] = value
# ALL-DUST GUARD (owner request, see CHANGELOG / log analysis).
#
# WHY HERE: ``locktimes`` now contains EVERY heir across EVERY locktime,
# with their final resolved amount already computed and dust-marked
# ("DUST: <n>" in HEIR_REAL_AMOUNT) by fixed_percent_lists_amount /
# normalize_perc above. This is the only place where we can reliably
# tell whether *all* heirs are dust, for BOTH fixed and percentage
# heirs and across all dates. ``prepare_transactions`` only sees the
# single lowest locktime, so checking there would wrongly block a will
# whose later locktimes still have valid heirs (false positive).
#
# WHAT: count the REAL heirs (excluding the internal will-executor
# pseudo-heirs, whose names start with the reserved ``w!ll3x3c"``
# marker) and how many of them have a valid, non-dust amount. If there
# are real heirs but NONE of them is payable, the inheritance would pay
# nobody (only the change + the will-executor fee). Previously such an
# "empty" will was still built, signed, checked and listed; we now
# refuse it and raise HeirAmountIsDustException so the GUI can show a
# clear message and stop. A mix of dust + valid heirs keeps building
# normally with the valid ones (unchanged behaviour).
real_heirs = 0
valid_real_heirs = 0
for heirs_at_locktime in locktimes.values():
for name, heir in heirs_at_locktime.items():
if str(name).startswith('w!ll3x3c"'):
continue
real_heirs += 1
if len(heir) > HEIR_REAL_AMOUNT and "DUST" not in str(
heir[HEIR_REAL_AMOUNT]
):
valid_real_heirs += 1
if real_heirs > 0 and valid_real_heirs == 0:
raise HeirAmountIsDustException(
"All heirs' shares are below the dust limit"
)
return locktimes, onlyfixed return locktimes, onlyfixed
def is_perc(self, key): def is_perc(self, key):

View File

@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
""" """
_version = None _version = None
__version__ = "0.3.9" # AUTOMATICALLY GENERATED DO NOT EDIT __version__ = "0.4.7" # 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 = {
@@ -190,11 +190,31 @@ class BalPlugin(BasePlugin):
# most one event per available day. # most one event per available day.
self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3) self.NUM_REMINDERS = BalConfig(config, "bal_num_reminders", 3)
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) # "Add transaction without will-executor" backup tx. Default OFF
# (False): a fresh wallet does NOT create the extra no-will-executor
# backup transaction (the "azure" tx), so a plain inheritance has no
# backup tx unless the user explicitly enables it from the wizard. The
# chosen value is persisted per wallet, so reopening the plugin always
# follows what is saved in that wallet (the default only applies when no
# value has been stored yet, i.e. new wallets).
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True) self.FIRST_EXECUTION = BalConfig(config, "bal_first_execution", True)
# SIMPLE / ADVANCED mode (global, plugin-wide).
#
# "basic" -> SIMPLE mode (DEFAULT): hides advanced controls (the
# Raw/Date selector and the "Check Alive" field/icon) and
# disables the "check alive" postpone behaviour, so the
# plugin is easier for non-technical users.
# "advanced" -> shows every control (the original behaviour).
#
# This is stored in Electrum's GLOBAL config (not in the wallet file),
# so it never affects compatibility with existing wallets: an old wallet
# simply opens with whatever global value is set, and its owner can
# switch to "advanced" from the plugin settings whenever they want.
self.USER_TYPE = BalConfig(config, "bal_user_type", "basic")
self.WELIST_SERVER = BalConfig( self.WELIST_SERVER = BalConfig(
config, "bal_welist_server", "https://welist.bitcoin-after.life/" config, "bal_welist_server", "https://welist.bitcoin-after.life/"
) )
@@ -315,6 +335,16 @@ class BalPlugin(BasePlugin):
will_settings["locktime"] = defaults['locktime'] will_settings["locktime"] = defaults['locktime']
return will_settings return will_settings
def is_basic_mode(self):
"""Return True when the plugin runs in SIMPLE ("basic") mode.
Centralises the USER_TYPE check so the GUI never compares the raw
string in many places. Anything other than the explicit "advanced"
value is treated as basic, so the safe/simple behaviour is the default
even if the stored value is missing or unexpected.
"""
return str(self.USER_TYPE.get()).lower() != "advanced"
@staticmethod @staticmethod
def default_will_settings(): def default_will_settings():
"""Default will settings: a fee rate plus absolute threshold/locktime.""" """Default will settings: a fee rate plus absolute threshold/locktime."""

View File

@@ -349,6 +349,47 @@ class Will:
Will.search_anticipate_rec(will, old_inputs) Will.search_anticipate_rec(will, old_inputs)
@staticmethod
def _same_heirs(old_heirs, new_heirs):
"""Return True if two heir maps describe the SAME inheritance.
Used by update_will (Option A) to decide whether a rebuilt transaction
that kept the same txid can safely reuse the old (possibly already
signed) WillItem, or whether the heirs changed and the item must be
rebuilt as unsigned.
Two heir maps are considered equal when they have exactly the same heir
names (keys) and, for each heir, the same destination ADDRESS, the same
requested AMOUNT and the same LOCKTIME. Internal will-executor
pseudo-heirs (keys starting with the reserved ``w!ll3x3c"`` prefix) are
ignored, exactly as in check_willexecutors_and_heirs, because they are
bookkeeping entries and not real heirs.
Args:
old_heirs: heirs dict stored in the old (existing) WillItem.
new_heirs: heirs dict of the freshly rebuilt WillItem.
Returns:
bool: True if the real heirs are identical, False otherwise.
"""
def _real_heirs(heirs):
# Keep only the real heirs and only the fields that define the
# inheritance (address/amount/locktime), so cosmetic or derived
# fields can never trigger a spurious "heirs changed" rebuild.
out = {}
for name, entry in (heirs or {}).items():
if str(name)[:9] == 'w!ll3x3c"':
continue
# Heir entry layout (see heirs.py): [0]=address, [1]=amount,
# [2]=locktime. We compare exactly the same fields that
# check_willexecutors_and_heirs uses (their[0], their[1],
# their[2]); index literals are used here to avoid importing the
# heirs module (which would create a circular import).
out[name] = (entry[0], entry[1], entry[2])
return out
return _real_heirs(old_heirs) == _real_heirs(new_heirs)
@staticmethod @staticmethod
def update_will(old_will, new_will): def update_will(old_will, new_will):
all_old_inputs = Will.get_all_inputs(old_will, only_valid=True) all_old_inputs = Will.get_all_inputs(old_will, only_valid=True)
@@ -368,9 +409,32 @@ class Will:
new_heirs = new_will[oid].heirs new_heirs = new_will[oid].heirs
new_we = new_will[oid].we new_we = new_will[oid].we
new_will[oid] = old_will[oid] # OPTION A (heir-change full rebuild, user-approved):
new_will[oid].heirs = new_heirs #
new_will[oid].we = new_we # Historically, whenever a rebuilt transaction kept the SAME
# txid as an old one, we REUSED the old WillItem object (which
# may already be signed/COMPLETE/PUSHED) and only copied the new
# heirs/will-executor onto it. That silently preserved the
# "already signed" status even when the HEIRS had actually
# changed (e.g. an heir was deleted, so amounts must be
# recomputed and the whole wallet re-swept). The downstream
# have_to_sign check then saw the item as COMPLETE and reported
# "Nothing to do", so the new will was never signed/broadcast
# (bugs E/F/K).
#
# We now reuse the old item ONLY when the heirs are IDENTICAL.
# If the heir set/values changed, we keep the freshly built
# item (status "New", not COMPLETE) so it is correctly detected
# as needing a new signature and broadcast. The will-executor is
# still refreshed in both cases.
if Will._same_heirs(old_will[oid].heirs, new_heirs):
new_will[oid] = old_will[oid]
new_will[oid].heirs = new_heirs
new_will[oid].we = new_we
else:
# Heirs changed: keep the new (unsigned) item but make sure
# it carries the up-to-date will-executor.
new_will[oid].we = new_we
continue continue
else: else:

View File

@@ -30,6 +30,14 @@ from .plugin_base import BalPlugin
# block the UI. # block the UI.
DEFAULT_TIMEOUT = 5 DEFAULT_TIMEOUT = 5
# Single, shared wall-clock deadline (seconds) for ALL network waits the user
# can watch in the GUI: the parallel broadcast (pushtxs), the parallel check
# (searchtx), the will-executor ping and the will-executor list download.
# Having ONE constant (instead of several scattered 30s/45s values) keeps the
# experience consistent and makes it trivial to tune. Requested by the user
# (reduced from 30s/45s to 20s, unified into one variable).
NETWORK_DEADLINE = 20
# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a # Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a
# couple of quick retries to survive a transient hiccup -- but far from the old # couple of quick retries to survive a transient hiccup -- but far from the old
# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server. # 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server.
@@ -43,7 +51,8 @@ PUSH_RETRY_SLEEP = 1
# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once # Global wall-clock deadline (seconds) for the whole parallel broadcast. Once
# it elapses we stop waiting for the still-pending servers, mark them as # it elapses we stop waiting for the still-pending servers, mark them as
# "Timeout" and let the wizard proceed instead of appearing stuck. # "Timeout" and let the wizard proceed instead of appearing stuck.
PUSH_GLOBAL_DEADLINE = 30 # Derived from the single shared NETWORK_DEADLINE constant above.
PUSH_GLOBAL_DEADLINE = NETWORK_DEADLINE
# Check (searchtx) timeouts. Used when the user presses "Check" to verify that # Check (searchtx) timeouts. Used when the user presses "Check" to verify that
# each will-executor still holds the transaction. Like the broadcast path, the # each will-executor still holds the transaction. Like the broadcast path, the
@@ -53,7 +62,8 @@ PUSH_GLOBAL_DEADLINE = 30
CHECK_TIMEOUT = 8 CHECK_TIMEOUT = 8
CHECK_MAX_RETRIES = 1 CHECK_MAX_RETRIES = 1
CHECK_RETRY_SLEEP = 1 CHECK_RETRY_SLEEP = 1
CHECK_GLOBAL_DEADLINE = 30 # Derived from the single shared NETWORK_DEADLINE constant above.
CHECK_GLOBAL_DEADLINE = NETWORK_DEADLINE
_logger = get_logger(__name__) _logger = get_logger(__name__)
@@ -68,6 +78,7 @@ class Willexecutors:
# importing module-level names. Single source of truth: the module # importing module-level names. Single source of truth: the module
# constants defined above. # constants defined above.
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT DEFAULT_TIMEOUT = DEFAULT_TIMEOUT
NETWORK_DEADLINE = NETWORK_DEADLINE
PUSH_TIMEOUT = PUSH_TIMEOUT PUSH_TIMEOUT = PUSH_TIMEOUT
PUSH_MAX_RETRIES = PUSH_MAX_RETRIES PUSH_MAX_RETRIES = PUSH_MAX_RETRIES
PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP

View File

@@ -62,7 +62,8 @@ from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
# --- Core (GUI-free) logic layer --- # --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, Heirs from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
HeirAmountIsDustException, Heirs)
from ...core.util import Util from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException, from ...core.will import (AmountException, HeirChangeException,
HeirNotFoundException, NoHeirsException, HeirNotFoundException, NoHeirsException,

View File

@@ -127,6 +127,21 @@ class BalWizardDialog(BalDialog):
def on_next_we(self): def on_next_we(self):
close_window = BalBuildWillDialog(self.bal_window) close_window = BalBuildWillDialog(self.bal_window)
close_window.build_will_task() close_window.build_will_task()
# Run the SAME final server check as the "Check" button (allegato15,
# case B): previously the wizard only ran build_will_task() and skipped
# the will-executor verification, so the user always had to press
# "Check" manually after finishing the wizard. We now replicate exactly
# the lists.py check() logic: after building, query every will that
# needs a server check (Will.needs_server_check) and run
# check_transactions(), which shows the "Checking transactions" dialog.
will = {}
for wid, w in self.bal_window.willitems.items():
if Will.needs_server_check(w):
will[wid] = w
if will:
self.bal_window.check_transactions(will)
self.close() self.close()
# self.next_widget(BalWizardLocktimeAndFeeWidget(self.bal_window,self,self.on_next_locktimeandfee,self.on_next_wedonwload,self.on_next_wedonwload.on_cancel_heir)) # self.next_widget(BalWizardLocktimeAndFeeWidget(self.bal_window,self,self.on_next_locktimeandfee,self.on_next_wedonwload,self.on_next_wedonwload.on_cancel_heir))
@@ -321,10 +336,30 @@ class BalWizardWEDownloadWidget(BalWizardWidget):
ping_on_done() ping_on_done()
def ping_on_done(): def ping_on_done():
# Task #02 - "Automatically download and select"
# (index 0): the green SELECTED tick must follow the
# green ping dot, i.e. select ONLY servers that actually
# answered the ping (status == 200) and DESELECT every
# server that did not (timeout / error / never pinged).
#
# Why the explicit deselect matters: previously a server
# that had been selected on an earlier download but is
# now unreachable stayed selected, so the plugin kept
# broadcasting to a dead server and got stuck. Forcing
# selected=False for non-200 servers discards them at the
# source. Re-running this (each "Automatically download"
# action) re-evaluates every server: one that failed
# before but now answers is selected again.
#
# We compare the status as a string ("200") to stay
# consistent with the will-executor list view
# (lists.py uses str(status) == "200"), and use .get()
# so a missing "status" key never raises.
if index < 1: if index < 1:
for we in self.bal_window.willexecutors: for we in self.bal_window.willexecutors:
if self.bal_window.willexecutors[we]["status"] == 200: wedict = self.bal_window.willexecutors[we]
self.bal_window.willexecutors[we]["selected"] = True responded = str(wedict.get("status", "")) == "200"
wedict["selected"] = responded
Willexecutors.save( Willexecutors.save(
self.bal_window.bal_plugin, self.bal_window.willexecutors self.bal_window.bal_plugin, self.bal_window.willexecutors
) )
@@ -502,10 +537,35 @@ class BalBuildWillDialog(BalDialog):
self.bal_window = bal_window self.bal_window = bal_window
self.bal_plugin = bal_window.bal_plugin self.bal_plugin = bal_window.bal_plugin
self.message_label = QLabel(_("Building Will:")) self.message_label = QLabel(_("Building Will:"))
# Allow the long report text to wrap instead of forcing the dialog ever
# wider, and let it grow downward inside the scroll area below.
self.message_label.setWordWrap(True)
self.message_label.setAlignment(
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft
)
self.vbox = QVBoxLayout(self) self.vbox = QVBoxLayout(self)
self.vbox.addWidget(self.message_label, 0)
# SCROLLABLE message area (allegato14): with many will-executors the
# report can reach dozens of lines. Previously the dialog kept resizing
# itself taller for every new line (see msg_update's resize), so with
# e.g. 50 will-executors the window grew past the screen and the bottom
# buttons (Close) became unreachable. We now put the message label in a
# QScrollArea with a capped maximum height: once the text exceeds that
# height a vertical scrollbar appears and the buttons stay visible.
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(self.message_label)
# Open the report area already ~500px tall (owner request, allegato1:
# the previous ~140px area was far too short). The dialog may still grow
# up to 700px to fit a few more lines; beyond that the vertical
# scrollbar takes over and the bottom buttons stay reachable.
self.scroll_area.setMinimumHeight(500)
self.scroll_area.setMaximumHeight(700)
self.vbox.addWidget(self.scroll_area, 1)
# Kept for backward compatibility (referenced by old/commented code);
# no longer used to lay out the messages.
self.qwidget = QWidget(self) self.qwidget = QWidget(self)
self.vbox.addWidget(self.qwidget, 1)
self.labelsbox = QVBoxLayout(self.qwidget) self.labelsbox = QVBoxLayout(self.qwidget)
self.setMinimumWidth(600) self.setMinimumWidth(600)
self.setMinimumHeight(100) self.setMinimumHeight(100)
@@ -519,6 +579,18 @@ class BalBuildWillDialog(BalDialog):
# Manual next-steps hint (Sign / Broadcast) shown to the user after the # Manual next-steps hint (Sign / Broadcast) shown to the user after the
# dialog finishes; None when nothing is left to do. # dialog finishes; None when nothing is left to do.
self._next_steps_hint = None self._next_steps_hint = None
# Set to True by _sync_locktime_to_built_txs when the delivery date was
# automatically anticipated during a rebuild. Used to explain to the
# user WHY signing is being requested (otherwise the sign prompt appears
# without any reason, as the owner reported).
self._date_was_anticipated = False
# Set to True right after we broadcast an automatic invalidation
# transaction (the "postpone" path). On the very next phase-1 re-check
# Electrum may not have seen the invalidation tx yet, so it would still
# report a postpone and the wizard would re-prompt to invalidate over
# and over (the reported loop). When this flag is set and a postpone is
# STILL detected, we STOP with a clear message instead of re-prompting.
self._invalidation_broadcast = False
self.network = Network.get_instance() self.network = Network.get_instance()
self._stopping = False self._stopping = False
self.thread = TaskThread(self) self.thread = TaskThread(self)
@@ -592,12 +664,22 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.check_will() self.bal_window.check_will()
self.msg_set_checking(self.msg_ok()) self.msg_set_checking(self.msg_ok())
except WillExpiredException: except WillExpiredException:
# UNIFY INVALIDATE PROCEDURE (+ task #03):
#
# The will is already expired (e.g. the CHECK button is pressed on an
# expired will). Previously this returned (None, invalidate_tx),
# which routed to the automatic invalidate path (password prompt +
# auto-broadcast) that did NOT set the "BAL Invalidate transaction"
# history label.
#
# We now return the SAME "invalidate_classic" signal used elsewhere,
# so on_success_phase1 shows the warning popup and auto-opens
# Electrum's classic transaction window (which sets the label). This
# makes the CHECK button and the WIZARD behave identically and fixes
# the missing-label bug (#03).
_logger.debug("expired") _logger.debug("expired")
self.msg_set_checking("Expired") self.msg_set_checking("Expired")
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) return "invalidate_classic", None
return None, Will.invalidate_will(
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
)
except WillPostponedException as e: except WillPostponedException as e:
# An already signed/sent will is being postponed. Like an expired # An already signed/sent will is being postponed. Like an expired
# will, the previously committed coins must be invalidated on-chain # will, the previously committed coins must be invalidated on-chain
@@ -628,12 +710,30 @@ class BalBuildWillDialog(BalDialog):
elif isinstance(e, TxFeesChangedException): elif isinstance(e, TxFeesChangedException):
message = _("Txfees are changed") message = _("Txfees are changed")
elif isinstance(e, HeirNotFoundException): elif isinstance(e, HeirNotFoundException):
message = _("Heir not found") # Task #01b: the old text "Heir not found" was misleading.
# In practice this branch is reached whenever the will is no
# longer coherent and must be rebuilt - very often simply
# because the delivery date was anticipated, NOT because an heir
# is genuinely missing. We therefore show a clear, accurate
# message that covers both the DATE and the HEIRS cases.
message = _(
"Found CHANGES to the DATE or the HEIRS,\n"
"a NEW WILL must be prepared."
)
if message: if message:
_logger.debug(f"message: {message}") _logger.debug(f"message: {message}")
self.msg_set_checking(message) self.msg_set_checking(message)
else: else:
self.msg_set_checking("New") # Task #01b: the old fallback text "New" was unclear. When the
# will is incomplete without a more specific reason, it still
# means the will has to be rebuilt, so we use the same clear
# message as the HeirNotFoundException branch above.
self.msg_set_checking(
_(
"Found CHANGES to the DATE or the HEIRS,\n"
"a NEW WILL must be prepared."
)
)
if have_to_build: if have_to_build:
self.msg_set_building() self.msg_set_building()
@@ -647,10 +747,7 @@ class BalBuildWillDialog(BalDialog):
return False, None return False, None
self.bal_window.check_will() self.bal_window.check_will()
for wid in Will.only_valid(self.bal_window.willitems): self._build_success_report()
# Label shown in Electrum's History tab for inheritance txs.
self.bal_window.wallet.set_label(wid, "BAL Inheritance transaction")
self.msg_set_building(self.msg_ok())
except WillExecutorNotPresent: except WillExecutorNotPresent:
self.msg_set_status( self.msg_set_status(
_("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR
@@ -683,21 +780,77 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_building(self.msg_warning(e)) self.msg_set_building(self.msg_warning(e))
return "invalidate_classic", None return "invalidate_classic", None
except NotCompleteWillException as e:
# IMPORTANT (bugs E/F/K): build_will() above has just REBUILT the
# whole will because the heirs (or the date) changed. The
# post-build re-validation (check_will) then legitimately reports
# that the new will differs from the previous one - e.g. it
# raises HeirNotFoundException for a heir that is not yet covered
# by an already-signed transaction. That is NOT an error: it is
# exactly the signal that the freshly built transactions still
# need to be SIGNED (and then broadcast).
#
# Previously this fell through to the generic "except Exception"
# below, which (1) printed the heir name in RED and (2) returned
# have_to_sign=False, so the rebuilt will was never signed
# ("Nothing to do"). We now treat it as a successful rebuild:
# show the green "Ok" + the heir list and FALL THROUGH to the
# have_to_sign detection, so the new (status "New", not COMPLETE)
# transactions are correctly detected and signed/broadcast.
_logger.debug(f"will rebuilt, needs signing: {e}")
self._build_success_report()
except HeirAmountIsDustException:
# ALL-DUST CASE (owner request): every heir's share is below the
# Bitcoin dust limit, so the inheritance would pay nobody. We do
# NOT build, sign or check anything - we show a clear message in
# red and stop, so no "empty" will ends up in the list. This is
# raised by Heirs.prepare_lists only when ALL real heirs are
# dust; a mix of dust + valid heirs never reaches here.
self.msg_set_building(
self.msg_error(
_(
"All heirs' shares are below the dust limit: "
"the inheritance cannot be created. "
"Increase the amounts or reduce the number of heirs."
)
)
)
return False, None
except Exception as e: except Exception as e:
self.msg_set_building(self.msg_error(e)) self.msg_set_building(self.msg_error(e))
return False, None return False, None
# excluded_heirs = [] # DUST report (one line PER HEIR, not per will-executor).
#
# WHY: the wallet balance can be so small that each heir's share falls
# below Bitcoin's dust limit, so the inheritance is not feasible. The
# "is DUST" condition depends ONLY on the heir's amount, NOT on which
# will-executor transaction we are looking at. The previous code looped
# over every valid will (one per will-executor) AND every heir, so with
# e.g. 20 will-executors and 10 heirs it printed 20x10 = 200 identical
# "is DUST ... Excluded from will <wid>" rows (owner report, allegato13).
#
# We now collect the dust heirs in a de-duplicated dict (heir id ->
# dust amount) across all valid wills and print ONE row per heir,
# without the will-executor reference. So N heirs => at most N rows,
# regardless of how many will-executors exist.
dust_heirs = {}
for wid in Will.only_valid(self.bal_window.willitems): for wid in Will.only_valid(self.bal_window.willitems):
heirs = self.bal_window.willitems[wid].heirs heirs = self.bal_window.willitems[wid].heirs
for hid, heir in heirs.items(): for hid, heir in heirs.items():
if "DUST" in str(heir[HEIR_REAL_AMOUNT]): if "DUST" in str(heir[HEIR_REAL_AMOUNT]):
self.msg_set_status( # Keep the first dust amount seen for this heir; it is the
f"{hid},{heir[HEIR_DUST_AMOUNT]} is DUST", # same share in every will-executor copy of the will.
None, dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT])
f"Excluded from will {wid}", for hid, dust_amount in dust_heirs.items():
self.COLOR_WARNING, self.msg_set_status(
) f"{_('Heir')} {hid}",
None,
f"{dust_amount} is DUST - excluded (amount below dust limit)",
self.COLOR_WARNING,
)
have_to_sign = False have_to_sign = False
for wid in Will.only_valid(self.bal_window.willitems): for wid in Will.only_valid(self.bal_window.willitems):
@@ -706,6 +859,116 @@ class BalBuildWillDialog(BalDialog):
break break
return have_to_sign, txs return have_to_sign, txs
def _build_success_report(self):
"""Mark the build step as done and list every heir in green.
Called after a successful (re)build of the will. It:
1. labels each inheritance transaction in Electrum's history;
2. shows the green "Ok" result on the "Building your will" row;
3. lists EVERY heir of the freshly built will, one per line, in green
(the "Ok" colour), so the user can confirm at a glance who will
inherit.
Previously a heir name could appear in RED (it was the text of a rebuild
exception) and the heir list was only shown on the "all clean" path,
which is why after changing/deleting an heir the user saw a single red
heir name and no green list. This helper is now used both on the clean
path and on the "will was rebuilt and needs signing" path, so the green
heir list is always shown.
Internal will-executor pseudo-heirs (reserved ``w!ll3x3c"`` prefix) are
skipped, exactly as the core coherence check does. A set keeps the list
unique even when an heir appears in several transactions.
"""
for wid in Will.only_valid(self.bal_window.willitems):
# Label shown in Electrum's History tab for inheritance txs.
self.bal_window.wallet.set_label(wid, "BAL Inheritance transaction")
# Keep the plugin's stored delivery date in sync with the (possibly
# auto-anticipated) transactions, otherwise the next Check would wrongly
# ask to invalidate. See _sync_locktime_to_built_txs for the full why.
self._sync_locktime_to_built_txs()
self.msg_set_building(self.msg_ok())
# List EACH heir on its OWN line, green + bold (owner request: revert the
# one-line "Heirs: a, b, c" form of v0.4.6). Heir names can be long, and
# now that the report area scrolls (allegato1) there is no need to cram
# them onto a single line. We de-duplicate (an heir can appear in several
# will-executor transactions) and skip the internal will-executor
# pseudo-heirs (reserved ``w!ll3x3c"`` prefix), exactly as before.
shown_heirs = set()
for wid in Will.only_valid(self.bal_window.willitems):
for hname in self.bal_window.willitems[wid].heirs:
if str(hname)[:9] == 'w!ll3x3c"':
continue
if hname in shown_heirs:
continue
shown_heirs.add(hname)
self.msg_set_status(_("Heir"), None, str(hname), self.COLOR_OK)
def _sync_locktime_to_built_txs(self):
"""Align the plugin's stored delivery date with the built transactions.
WHY this is needed (bug reported by the owner):
When the will is rebuilt while it still spends the same coins as a
previous one (e.g. after deleting an heir WITHOUT changing the date),
the core engine AUTOMATICALLY anticipates the transaction locktime by
one day (see Will.check_anticipate / Util.anticipate_locktime). This is
correct and required so the new transaction can be mined BEFORE the old
one it replaces.
However the plugin's own stored delivery date
(WILL_SETTINGS["locktime"]) was NOT updated and stayed at the original
date. On the next Check the plugin compared the stored date (original)
with the transaction locktime (original minus one day) and, since
stored > tx, mistook the automatic anticipation for a user POSTPONE,
wrongly asking to invalidate the will.
Fix: after a (re)build, set the stored delivery date to the MINIMUM
locktime among the valid built transactions. We only ever move the date
EARLIER (anticipation): if the minimum is not strictly below the current
stored date we leave it untouched, so a genuine user-chosen postpone is
never silently overwritten. The owner confirmed that, when several
transactions carry different locktimes, taking the minimum is the
desired behaviour, and that the date shown in the panel/wizard must
reflect this anticipated date (so the calendar .ics also uses it).
We route the update through BalWindow.update_setting_widgets, which is
the single place that (1) stores the value in WILL_SETTINGS, (2)
persists it to Electrum's database and (3) refreshes the date widgets in
every panel/wizard, so the visible date and the .ics export stay
consistent.
"""
# Minimum locktime across the valid (just built) inheritance txs.
# Will.get_min_locktime returns None when there is no valid tx.
min_locktime = Will.get_min_locktime(self.bal_window.willitems, None)
if min_locktime is None:
return
min_locktime = int(min_locktime)
# Current stored delivery date, as a comparable UNIX timestamp.
try:
current = int(
Util.parse_locktime_string(
self.bal_window.will_settings["locktime"]
)
)
except Exception:
# If the stored value cannot be parsed, fall back to syncing.
current = None
# Only anticipate (move the date EARLIER); never overwrite a postpone.
if current is not None and min_locktime >= current:
return
_logger.debug(
f"sync delivery date to anticipated tx locktime: "
f"{current} -> {min_locktime}"
)
# Remember that we anticipated the date, so the later sign prompt can
# explain WHY signing is needed (see on_success_phase1).
self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and refreshes the
# date widgets in all panels/wizard (so the .ics calendar uses it too).
self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True
)
def on_accept(self): def on_accept(self):
self.bal_window.update_all() self.bal_window.update_all()
pass pass
@@ -733,12 +996,34 @@ class BalBuildWillDialog(BalDialog):
try: try:
tx.add_info_from_wallet(self.bal_window.wallet) tx.add_info_from_wallet(self.bal_window.wallet)
self.network.run_from_another_thread(tx.add_info_from_network(self.network)) self.network.run_from_another_thread(tx.add_info_from_network(self.network))
txid = self.network.run_from_another_thread(
# IMPORTANT (task #21 fix): get the txid from the transaction
# object, NOT from broadcast_transaction()'s return value.
# Network.broadcast_transaction is declared "-> None" and ALWAYS
# returns None (it only raises on failure). The previous code stored
# that None into `txid` and put set_label() in the `else: # txid`
# branch, which was therefore NEVER reached - that is why the
# "BAL Invalidate transaction" history label kept missing on this
# automatic ("postpone") path. The transaction is already signed and
# complete here, so tx.txid() is the correct, stable id - exactly
# what the working Tools -> Invalidate path uses
# (BalWalletWindow.invalidate_will -> result.txid()).
txid = tx.txid()
# Set the history label BEFORE broadcasting. set_label only writes to
# the local wallet metadata (no network needed), so doing it first
# guarantees the label exists the moment the transaction shows up in
# the History tab, regardless of how fast the broadcast/notification
# arrives.
if txid:
self.bal_window.wallet.set_label(txid, "BAL Invalidate transaction")
else:
_logger.debug(f"invalidate tx has no txid: {tx}")
self.network.run_from_another_thread(
self.network.broadcast_transaction(tx, timeout=120), timeout=120 self.network.broadcast_transaction(tx, timeout=120), timeout=120
) )
self.msg_set_invalidating(self.msg_ok()) self.msg_set_invalidating(self.msg_ok())
if not txid:
_logger.debug(f"should not be none txid: {txid}")
except TxBroadcastError as e: except TxBroadcastError as e:
_logger.error(f"fail to broadcast transaction:{e}") _logger.error(f"fail to broadcast transaction:{e}")
@@ -909,7 +1194,19 @@ class BalBuildWillDialog(BalDialog):
if tx: if tx:
if tx.is_complete(): if tx.is_complete():
self.loop_broadcast_invalidating(tx) self.loop_broadcast_invalidating(tx)
self.wait(5) # Wait 10 seconds (was 5) AFTER broadcasting the
# invalidation so Electrum's wallet/network has time to see
# the new transaction before we re-run phase 1. Without this
# pause the immediate re-check still detected the old
# (not-yet-invalidated) will and re-prompted to invalidate,
# producing the reported loop. This runs in the worker
# thread, so the GUI is not frozen by the sleep.
self.wait(10)
# Remember that we just broadcast an invalidation. If the
# next phase-1 re-check STILL reports a postpone (because
# Electrum has not registered the tx yet), we stop with a
# clear message instead of looping (see on_success_phase1).
self._invalidation_broadcast = True
else: else:
raise Exception("tx not complete") raise Exception("tx not complete")
else: else:
@@ -946,46 +1243,74 @@ class BalBuildWillDialog(BalDialog):
# is safe. We then stop and close the wizard. # is safe. We then stop and close the wizard.
if self.have_to_sign == "invalidate_classic": if self.have_to_sign == "invalidate_classic":
self.thread.stop() self.thread.stop()
# Design decision (window stacking + user clarity): # UNIFY INVALIDATE PROCEDURE (+ task #03):
# #
# When an heir is added to an already-expired will, the rebuilt will # When an heir is added to an already-expired will, the rebuilt will
# is itself expired and the old will must be invalidated on-chain # is itself expired and the old will must be invalidated on-chain
# before the new one can be used. We previously tried to open the # before the new one can be used.
# invalidation transaction window AUTOMATICALLY from here, but doing
# so from within the closing wizard proved fragile: depending on the
# OS window manager and Qt's event ordering, the transaction window
# kept ending up BEHIND the main wallet window (it lost focus when
# the wizard closed). Neither closing-before-opening nor a deferred
# QTimer close() fixed it reliably on every machine.
# #
# The robust solution is to NOT auto-open any window here. Instead we # We make the CHECK button and the WIZARD behave IDENTICALLY:
# close the wizard and show a clear instruction telling the user to # 1. show a WARNING popup (no "Tools -> Invalidate" wording);
# run "Tools -> Invalidate" themselves. That menu path is already # 2. AUTOMATICALLY open Electrum's classic transaction dialog via
# known to work perfectly (its transaction window always stays in # BalWalletWindow.invalidate_will() (the same code used by the
# front, because no other window is closing at the same time), and # Tools -> Invalidate menu). That path already sets the
# it also makes the user consciously aware that they are performing a # "BAL Invalidate transaction" history label - which fixes
# deliberate, important action (invalidating their old will). # task #03 (the label was previously missing on the automatic
# path).
# #
# Close the wizard first so the instruction popup is the only window # Window-stacking note (history): opening the transaction window
# left, then show the guidance message. # straight from within the closing wizard used to leave it BEHIND
# the main window on some window managers. The robust fix is to
# close the wizard FIRST and defer the call with QTimer.singleShot
# so it runs on the next event-loop iteration, when the wizard is
# already gone and the transaction window becomes the front-most,
# focused window.
self.close() self.close()
self.bal_window.show_message( self.bal_window.show_message(
_( _(
"Your will has expired and must be invalidated before it " "Your will has expired and must be invalidated before it "
"can be rebuilt.\n\n" "can be rebuilt.\n"
"Please use the top-right menu Tools -> Invalidate to " "A transaction window will now open:\n"
"invalidate your old will: a transaction window will open " "please SIGN and then BROADCAST it to invalidate your old "
"where you can sign and broadcast the invalidation.\n\n" "will.\n"
"After the invalidation is confirmed, press the Check " "After the invalidation is confirmed, press the Check "
"button near Tools, to finish the will." "button to finish the will."
) )
) )
# Deferred so the wizard is fully closed before the classic
# invalidate window opens (keeps it in front, fixes the old
# "window behind" problem).
QTimer.singleShot(0, self.bal_window.invalidate_will)
return return
_logger.debug("have to sign {}".format(self.have_to_sign)) _logger.debug("have to sign {}".format(self.have_to_sign))
password = None password = None
if self.have_to_sign is None: if self.have_to_sign is None:
_logger.debug("have to invalidate") _logger.debug("have to invalidate")
# LOOP GUARD (task #21): if we already broadcast an invalidation on
# the previous pass and phase 1 STILL reports a postpone, Electrum
# has simply not seen the invalidation transaction yet. Re-prompting
# to invalidate here is exactly what produced the reported endless
# loop ("Invalidate your old will" reappearing right after signing).
# Instead of re-prompting, STOP cleanly with a clear message and
# tell the user to retry the Check once the invalidation confirms.
if self._invalidation_broadcast:
self.thread.stop()
self.msg_set_invalidating(self.msg_ok())
self.bal_window.show_message(
_(
"Your old will has been invalidated and the "
"transaction was broadcast.\n"
"Electrum may need a little time to register it.\n"
"Please wait until the invalidation transaction is "
"confirmed, then press the Check button again to "
"finish updating your will."
)
)
self._add_close_button()
return
self.msg_set_invalidating() self.msg_set_invalidating()
# need to sign invalidate and restart phase 1 # need to sign invalidate and restart phase 1
@@ -1017,6 +1342,26 @@ class BalBuildWillDialog(BalDialog):
return return
elif self.have_to_sign: elif self.have_to_sign:
# If the will was rebuilt with an automatically anticipated delivery
# date, explain WHY we are now asking to sign: otherwise the sign
# prompt appears with no reason (owner feedback). The note is shown
# on the "Building your will" row (orange warning) before the modal
# password prompt opens, so the user can read it.
if self._date_was_anticipated:
# Render this notice in BLACK BOLD (not the yellow warning
# colour) and split it onto TWO lines after "...previous one."
# for readability (allegato17). The "\n" is converted to a line
# break by msg_update (it replaces "\n" with "<br>").
self.msg_set_building(
"<b>{}</b>".format(
_(
"The delivery date was automatically moved one day "
"earlier so the updated will can correctly replace "
"the previous one.\n"
"Please sign (and broadcast) to confirm the change."
)
)
)
password = self.bal_window.get_wallet_password( password = self.bal_window.get_wallet_password(
_("Sign your will"), parent=self _("Sign your will"), parent=self
) )
@@ -1307,8 +1652,18 @@ class BalBuildWillDialog(BalDialog):
full_text = "<br><br>".join(self.labels).replace("\n", "<br>") full_text = "<br><br>".join(self.labels).replace("\n", "<br>")
self.message_label.setText(full_text) self.message_label.setText(full_text)
self.message_label.adjustSize() self.message_label.adjustSize()
# self.setMinimumHeight(len(self.labels)*40) # Auto-scroll the report to the BOTTOM so the newest line is always
# visible (allegato14). The QScrollArea caps the height, so instead of
# resizing the whole dialog taller we move its vertical scrollbar to the
# maximum. ensureWidgetVisible is deferred via the scrollbar range so it
# reflects the just-added text.
scrollbar = self.scroll_area.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
# Let the dialog grow only up to the scroll area's capped height; beyond
# that the scrollbar handles overflow and the buttons stay reachable.
self.resize(self.sizeHint()) self.resize(self.sizeHint())
# Re-assert the bottom position after the resize/relayout settled.
scrollbar.setValue(scrollbar.maximum())
def get_text(self): def get_text(self):
return self.message_label.text() return self.message_label.text()

View File

@@ -491,8 +491,10 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.bal_window.bal_plugin.read_file("icons/reload.png") self.bal_window.bal_plugin.read_file("icons/reload.png")
) )
) )
# Tooltip so the icon is self-explanatory when hovered. # Tooltip so the icon is self-explanatory when hovered. "Check
refresh.setToolTip(_("Check")) # Inheritance" makes it clear the button re-checks the inheritance/will
# state (not a generic refresh).
refresh.setToolTip(_("Check Inheritance"))
refresh.clicked.connect(self.check) refresh.clicked.connect(self.check)
widget = QWidget(self) widget = QWidget(self)

View File

@@ -417,6 +417,32 @@ class Plugin(BalPlugin):
# be saved on a USB stick and a copy given to the heirs). # be saved on a USB stick and a copy given to the heirs).
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR) heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
# (not a free-text field) bound to the USER_TYPE config:
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
# index 1 -> "ADVANCED" -> stored value "advanced"
#
# BASIC hides the advanced controls (Raw/Date selector and the
# "Check Alive" field) and disables the check-alive postpone behaviour.
# Changing it refreshes the open windows so the controls appear/disappear
# immediately. It is kept in a named variable so the "Reset" button can
# restore its displayed value after a reset.
user_type_combo = QComboBox()
user_type_combo.addItems([_("BASIC"), _("ADVANCED")])
# Map the stored string to the combo index (anything but "advanced"
# falls back to BASIC, matching BalPlugin.is_basic_mode()).
user_type_combo.setCurrentIndex(
0 if str(self.USER_TYPE.get()).lower() != "advanced" else 1
)
def on_user_type_change(idx):
# Persist "basic"/"advanced" and refresh the open windows so the
# advanced controls appear/disappear right away.
self.USER_TYPE.set("advanced" if idx == 1 else "basic")
self.update_all()
user_type_combo.currentIndexChanged.connect(on_user_type_change)
# Editable line/text widgets are created once and kept in named # Editable line/text widgets are created once and kept in named
# variables so the "Reset" button (Group C / C4b) can refresh the # variables so the "Reset" button (Group C / C4b) can refresh the
# displayed values after resetting the underlying config. # displayed values after resetting the underlying config.
@@ -447,6 +473,21 @@ class Plugin(BalPlugin):
# Reset/support button row (bottom). Assigning ``QGridLayout(d)`` would # Reset/support button row (bottom). Assigning ``QGridLayout(d)`` would
# have made the grid the dialog's only layout, leaving no room for them. # have made the grid the dialog's only layout, leaving no room for them.
grid = QGridLayout() grid = QGridLayout()
add_widget(
grid,
"User Type",
user_type_combo,
0,
(
"Choose how much detail the plugin shows.\n\n"
"BASIC (default): a simpler interface. It hides the advanced "
"controls (the Raw/Date selector and the 'Check Alive' field) "
"and turns off the 'check alive' postpone behaviour, so you "
"only set the delivery date.\n\n"
"ADVANCED: shows every control, including the 'Check Alive' "
"field and the Raw/Date selector."
),
)
add_widget( add_widget(
grid, grid,
"Hide Replaced", "Hide Replaced",
@@ -475,7 +516,7 @@ class Plugin(BalPlugin):
) )
add_widget( add_widget(
grid, grid,
"Editable dates", "Panel editable Date and Fee",
heir_editable_dates, heir_editable_dates,
4, 4,
( (
@@ -485,14 +526,38 @@ class Plugin(BalPlugin):
"When disabled, those dates are display-only outside the wizard." "When disabled, those dates are display-only outside the wizard."
), ),
) )
# "Add transaction without will-executor" setting (formerly labelled
# "No will-executor TX"). When ON the plugin ALSO builds the backup
# inheritance transaction that does NOT require a will-executor (the
# "celeste"/light-blue one shown in the will list): it can be saved on a
# USB stick and a copy handed to the heirs. When OFF only the
# transactions destined to the selected will-executors are built.
#
# Placed here (row 5, right below "Panel editable Date and Fee" and above
# "Number of reminders") at the user's request so related options sit
# together. The remaining grid rows below were renumbered accordingly.
add_widget(
grid,
"Add transaction without willexecutor",
heir_no_willexecutor,
5,
(
"Create a will that does not require a Will-executor; it can be "
"saved, for example, on a USB stick, and a copy can be given to "
"the heirs."
),
)
add_widget( add_widget(
grid, grid,
"Number of reminders", "Number of reminders",
heir_num_reminders, heir_num_reminders,
5, 6,
( (
"How many reminder alarms the exported calendar (.ics) event " "How many reminder alarms the exported calendar (.ics) event "
"contains.\n" "contains.\n\n"
"BASIC MODE:\n"
"Calendar reminder 30, 10 and 1 days before.\n\n\n"
"ADVANCED MODE:\n"
"The reminders are spread across the check-alive period and " "The reminders are spread across the check-alive period and "
"always fall before the delivery deadline.\n" "always fall before the delivery deadline.\n"
"If the period is shorter than the requested number, at most " "If the period is shorter than the requested number, at most "
@@ -503,7 +568,7 @@ class Plugin(BalPlugin):
grid, grid,
"Event summary", "Event summary",
edit_event_summary, edit_event_summary,
6, 7,
( (
"Default message to be used in event summary\n" "Default message to be used in event summary\n"
"Variables:\n" "Variables:\n"
@@ -516,7 +581,7 @@ class Plugin(BalPlugin):
grid, grid,
"Event description", "Event description",
edit_event_description, edit_event_description,
7, 8,
( (
"Default message to be used in event description\n" "Default message to be used in event description\n"
"Variables:\n" "Variables:\n"
@@ -527,21 +592,6 @@ class Plugin(BalPlugin):
) )
#add_widget(grid, "Bal Mode", bal_mode, 4, "choose bal mode") #add_widget(grid, "Bal Mode", bal_mode, 4, "choose bal mode")
# "No will-executor TX" setting. Mirrors the checkbox shown in the
# wizard's will-executor download window (both bound to NO_WILLEXECUTOR),
# so it can also be toggled from the plugin settings. Default ON.
add_widget(
grid,
"No will-executor TX",
heir_no_willexecutor,
8,
(
"Create a will that does not require a Will-executor; it can be "
"saved, for example, on a USB stick, and a copy can be given to "
"the heirs."
),
)
# add_widget( # add_widget(
# grid, # grid,
# "Ping Willexecutors", # "Ping Willexecutors",
@@ -584,6 +634,7 @@ class Plugin(BalPlugin):
# Map each config object to the widget that displays it, so we can # Map each config object to the widget that displays it, so we can
# both reset the stored value and update what the user sees. # both reset the stored value and update what the user sees.
resets = [ resets = [
(self.USER_TYPE, user_type_combo, "user_type"),
(self.HIDE_REPLACED, heir_hide_replaced, "check"), (self.HIDE_REPLACED, heir_hide_replaced, "check"),
(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), (self.HIDE_INVALIDATED, heir_hide_invalidated, "check"),
(self.AUTO_SIGN, heir_auto_sign, "check"), (self.AUTO_SIGN, heir_auto_sign, "check"),
@@ -607,6 +658,11 @@ class Plugin(BalPlugin):
widget.setText(cfg.default) widget.setText(cfg.default)
elif kind == "text": elif kind == "text":
widget.setPlainText(cfg.default) widget.setPlainText(cfg.default)
elif kind == "user_type":
# Default is "basic" -> combo index 0; "advanced" -> index 1.
widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0
)
# Refresh the open BAL windows so any dependent view (e.g. the # Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are) # editable-dates state is not in this list, but hide filters are)
# reflects the reset values. # reflects the reset values.
@@ -638,6 +694,9 @@ class Plugin(BalPlugin):
# Outer layout: warning (top) -> settings grid -> bottom button row. # Outer layout: warning (top) -> settings grid -> bottom button row.
outer = QVBoxLayout(d) outer = QVBoxLayout(d)
outer.addWidget(lbl_warning) outer.addWidget(lbl_warning)
# Blank vertical gap below the red warning so it is not glued to the
# first setting row ("User Type"); requested by the user for readability.
outer.addSpacing(12)
outer.addLayout(grid) outer.addLayout(grid)
outer.addLayout(bottom_row) outer.addLayout(bottom_row)

View File

@@ -75,6 +75,34 @@ def compute_reminder_offsets(days, count):
return sorted(offsets, reverse=True) return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
doubleClicked = pyqtSignal() doubleClicked = pyqtSignal()
@@ -256,6 +284,13 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
1: self.locktime_date_e, 1: self.locktime_date_e,
} }
self.combo.addItems(options) self.combo.addItems(options)
# SIMPLE / ADVANCED (task: hide the Raw/Date selector in BASIC mode).
#
# In BASIC mode the user must not see or use the Raw/Date selector:
# every date field is forced to the calendar ("Date") editor and the
# combo is hidden. We keep a flag so the rest of __init__ can force the
# Date editor regardless of the stored value's format.
self._basic_mode = self.bal_window.bal_plugin.is_basic_mode()
default_index = 0 default_index = 0
if not default_locktime: if not default_locktime:
default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field] default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field]
@@ -264,6 +299,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
default_index = 1 default_index = 1
except Exception: except Exception:
default_index = 0 default_index = 0
# Force the calendar ("Date") editor in BASIC mode so the user always
# picks a date and never sees the RAW input ("30d"/"1y" style).
if self._basic_mode:
default_index = 1
#hbox.addWidget(QLabel(self.label_text)) #hbox.addWidget(QLabel(self.label_text))
help_button=HelpButton(self.help_text) help_button=HelpButton(self.help_text)
help_button.setText(self.label_text) help_button.setText(self.label_text)
@@ -290,6 +329,11 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
self.set_value(default_locktime) self.set_value(default_locktime)
self.current_value=default_locktime self.current_value=default_locktime
hbox.addWidget(self.combo) hbox.addWidget(self.combo)
# In BASIC mode hide the Raw/Date selector entirely: the field stays
# locked on the calendar editor chosen above, so the user only ever
# picks a Date and cannot switch to RAW.
if self._basic_mode:
self.combo.setVisible(False)
for w in self.editors: for w in self.editors:
hbox.addWidget(w) hbox.addWidget(w)
@@ -594,13 +638,19 @@ class LockTimeWidget(BalTimeEditWidget):
"<b>DELIVERY TIME</b><br><br>" "<b>DELIVERY TIME</b><br><br>"
"Set Locktime for transactions.<br>" "Set Locktime for transactions.<br>"
"Any time is needed transaction will be anticipated by 1day<br><br>" "Any time is needed transaction will be anticipated by 1day<br><br>"
# The Raw locktime syntax below is only available in ADVANCED mode
# (in BASIC mode the Raw/Date selector is hidden and only the Date
# picker is shown), so we say so explicitly to avoid confusing users.
"(ONLY IN ADVANCED MODE)<br>"
"if you choose Raw, you can insert various options based on suffix:<br>" "if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>" " - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>" " - y: number of years after currrent day(ex: 1y means one year from today)<br>"
) )
label_text = "🚛" label_text = "🚛"
#label_text = "Locktime" #label_text = "Locktime"
tooltip_text = "Delivery time" # Hover tooltip for the delivery-time icon; mirrors the style of the fee
# icon tooltip ("..., click for more information") so the two are consistent.
tooltip_text = "Delivery Time, click for more information"
base_field = "locktime" base_field = "locktime"
def __init__(self, bal_window, parent, init_value=None): def __init__(self, bal_window, parent, init_value=None):
@@ -641,6 +691,15 @@ class WillSettingsWidget(QWidget):
self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self) self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self)
self.widgets["locktime"].valueEdited.connect(self.on_locktime_change) self.widgets["locktime"].valueEdited.connect(self.on_locktime_change)
self.widgets["threshold"].valueEdited.connect(self.on_locktime_change) self.widgets["threshold"].valueEdited.connect(self.on_locktime_change)
# SIMPLE / ADVANCED: in BASIC mode hide the whole "Check Alive"
# (threshold) row, including its leading icon. The widget is still
# created and kept in self.widgets so the rest of the code (and the
# saved settings) keep working; it is only hidden from view. The
# Delivery time (locktime) row stays visible. We hide it after creation
# so both the horizontal (toolbar/Heirs) and vertical (wizard) layouts
# below add an already-hidden widget.
if bal_window.bal_plugin.is_basic_mode():
self.widgets["threshold"].setVisible(False)
# self.widgets['baltx_fees'].valueChange.connect(self.bal_window.update_setting_widgets) # self.widgets['baltx_fees'].valueChange.connect(self.bal_window.update_setting_widgets)
self.on_locktime_change() self.on_locktime_change()
self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self) self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self)
@@ -656,53 +715,135 @@ class WillSettingsWidget(QWidget):
box.addWidget(self.calendar_button) box.addWidget(self.calendar_button)
box.addWidget(self.widgets["baltx_fees"]) box.addWidget(self.widgets["baltx_fees"])
else: else:
# Vertical layout (the "Build your will" wizard): make every row the # ----------------------------------------------------------------- #
# same width and left aligned so they all fit in one tidy block, # Vertical layout (the "Build your will" wizard) - Layout H. #
# instead of letting the calendar button and the fee field stretch to # #
# the dialog's right edge (which made them far wider than the date # User requirements (allegato4): #
# rows above). # * the leading ICONS (delivery time, calendar, fee and - in #
# # ADVANCED mode - check-alive) must be aligned one under the #
# IMPORTANT: the leading icons keep their ORIGINAL size. The icons # other on the left; #
# are HelpButtons, which already pin themselves to a fixed width # * the editable FIELDS must all start from the SAME x position, #
# (2.2 * char_width_in_lineedit()); we must NOT widen them, otherwise # just to the right of their icon; #
# they look oversized compared with the original toolbar layout. We # * the fee field must be WIDER (its up/down arrows were covering #
# only need to (1) align the calendar row's left edge with the icons' # the digits) - not a tiny box. #
# original width and (2) cap every row to the date-row width. # #
# DESIGN NOTE - why we do NOT pull the icon/field out of each #
# composite into a shared grid: the LockTime / Check-Alive composites #
# hold TWO editors (Raw and Date) plus a Raw/Date combo that the user #
# can switch at runtime in ADVANCED mode; ``self.editor`` is swapped #
# live (see on_current_index_changed). Reparenting only the currently #
# active editor would orphan the other editor and the combo and break #
# ADVANCED mode. So we keep every composite INTACT and instead align #
# them by: #
# 1. forcing every leading icon (prefix_widget) to the SAME fixed #
# width, so each composite's field starts at the same x; and #
# 2. stacking the whole composites left-aligned in the VBox. #
# This is robust to the Raw/Date switching and keeps all internal #
# logic working untouched. #
# ----------------------------------------------------------------- #
locktime_w = self.widgets["locktime"] locktime_w = self.widgets["locktime"]
threshold_w = self.widgets["threshold"] threshold_w = self.widgets["threshold"]
fees_w = self.widgets["baltx_fees"] fees_w = self.widgets["baltx_fees"]
# Original icon width (HelpButton's own fixed width); used only to # Common icon width = the widest leading icon (incl. the calendar
# offset the calendar button so its field starts under the others. # button). Forcing every icon to this width makes the icons line up
icon_w = locktime_w.prefix_widget.sizeHint().width() # one under the other and, because each field sits immediately to the
# right of its icon, makes every field start at the same x too.
# Common row width = natural width of the date rows (the reference). icon_w = max(
row_w = max( locktime_w.prefix_widget.sizeHint().width(),
locktime_w.sizeHint().width(), threshold_w.prefix_widget.sizeHint().width(),
threshold_w.sizeHint().width(), fees_w.prefix_widget.sizeHint().width(),
self.calendar_button.sizeHint().width(),
) )
for w in (locktime_w, threshold_w, fees_w): for icon in (
w.setFixedWidth(row_w) locktime_w.prefix_widget,
threshold_w.prefix_widget,
fees_w.prefix_widget,
self.calendar_button,
):
icon.setFixedWidth(icon_w)
# The calendar row has no prefix icon: wrap it so it starts with an # WIDEN the fee field (user feedback: the spin-box up/down arrows
# empty spacer of the icon width (calendar field aligned with the # were covering the digits). ~8 chars leaves room for the value and
# date/fee fields) and cap it to the same total width as the rows # the arrows.
# above, so it no longer stretches to the dialog's right edge. fees_w.field_widget.setFixedWidth(8 * char_width_in_lineedit())
calendar_row = QWidget(self)
calendar_box = QHBoxLayout(calendar_row)
calendar_box.setContentsMargins(0, 0, 0, 0)
calendar_box.setSpacing(0)
calendar_spacer = QWidget()
calendar_spacer.setFixedWidth(icon_w)
calendar_box.addWidget(calendar_spacer)
calendar_box.addWidget(self.calendar_button)
calendar_row.setFixedWidth(row_w)
# WHY THE TEXT WAS TRUNCATED (allegato16, fixed here):
# A QLabel added to a box layout WITH an alignment flag (the old
# ``alignment=Qt.AlignmentFlag.AlignLeft``) is NOT stretched to the
# layout width - Qt gives it only its sizeHint. For a word-wrapped
# label that sizeHint width is ambiguous/narrow, so the text wrapped
# against an almost-minimum width and the reserved height was too
# small, cutting the sentence in half. setMinimumWidth alone did not
# help because the alignment flag still prevented horizontal stretch.
#
# TARGETED FIX:
# 1. give the whole vertical WillSettingsWidget a sensible minimum
# width, so the box (and thus the labels) has real width to work
# with even before the parent dialog stretches it;
# 2. add the two explanatory labels WITHOUT an alignment flag, so
# they expand to the full box width and word-wrap correctly;
# 3. set an Expanding/Minimum size policy so the label takes the
# available width and computes its height from that width
# (heightForWidth), guaranteeing the full text is shown.
hint_min_width = 44 * char_width_in_lineedit()
self.setMinimumWidth(hint_min_width)
# Explanatory hint ABOVE the date field (wizard only): tell the user
# what the delivery date means. Wrapped so it fits the dialog width.
# The explicit "\n" forces the line break exactly where the owner
# asked (allegato2): after "(or backup)". With setWordWrap(True) the
# QLabel honours the newline, so the sentence always shows on two
# tidy lines instead of wrapping at an arbitrary point.
date_hint = QLabel(
_(
"Enter the date on which you want the inheritance (or "
"backup)\nof your Electrum wallet to take effect."
)
)
date_hint.setWordWrap(True)
date_hint.setMinimumWidth(hint_min_width)
date_hint.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
)
# NOTE: added WITHOUT an alignment flag on purpose (see above), so
# the label is stretched to the full width and wraps correctly.
box.addWidget(date_hint)
# Stack the composites one under the other, all left-aligned so the
# fixed-width icons share a common left edge.
box.addWidget(locktime_w, alignment=Qt.AlignmentFlag.AlignLeft) box.addWidget(locktime_w, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(threshold_w, alignment=Qt.AlignmentFlag.AlignLeft) box.addWidget(threshold_w, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(calendar_row, alignment=Qt.AlignmentFlag.AlignLeft) # In BASIC mode the Check-Alive (threshold) row is hidden entirely
# (it was already set invisible above); in ADVANCED mode it shows
# with its icon aligned under the delivery-time icon.
box.addWidget(
self.calendar_button, alignment=Qt.AlignmentFlag.AlignLeft
)
box.addWidget(fees_w, alignment=Qt.AlignmentFlag.AlignLeft) box.addWidget(fees_w, alignment=Qt.AlignmentFlag.AlignLeft)
# Cautionary note BELOW the miner-fee field (wizard only): warn the
# user not to lower the miner fee unless they know what they do.
# Explicit "\n" break after "miner fees" (allegato2), same rationale
# as date_hint above.
fee_note = QLabel(
_(
"Please note: Do not reduce the miner fees\nunless you "
"know what you\u2019re doing"
)
)
fee_note.setWordWrap(True)
fee_note.setMinimumWidth(hint_min_width)
fee_note.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
)
# Added WITHOUT an alignment flag (see date_hint above) so it
# stretches to full width and wraps correctly instead of truncating.
box.addWidget(fee_note)
self.apply_editable_dates()
return
# Group C / C2: apply the current "Editable dates" setting to the date # Group C / C2: apply the current "Editable dates" setting to the date
# fields. Done once at creation here, and re-applied later by # fields. Done once at creation here, and re-applied later by
# apply_editable_dates() whenever the setting changes (called from # apply_editable_dates() whenever the setting changes (called from
@@ -744,6 +885,39 @@ class WillSettingsWidget(QWidget):
# editable outside the wizard only when the setting is ticked. # editable outside the wizard only when the setting is ticked.
self.widgets["baltx_fees"].set_read_only(not editable_dates) self.widgets["baltx_fees"].set_read_only(not editable_dates)
def apply_user_type_visibility(self):
"""Re-apply the BASIC/ADVANCED visibility of the Check-Alive field.
WHY this is needed (bug reported by the owner): the Check-Alive
(threshold) field is hidden in BASIC mode and shown in ADVANCED mode.
That visibility used to be decided ONLY in __init__. The toolbar
settings widgets of the WILL and HEIR tabs are created once and then
REUSED across the session (they are not rebuilt when the user switches
USER TYPE), so after switching from BASIC to ADVANCED the Check-Alive
field stayed hidden there. The wizard worked only because it is recreated
every time it is opened.
This method re-reads is_basic_mode() and shows/hides the Check-Alive
field accordingly. It is called from BalWindow.update_all() (which the
USER TYPE combo triggers when changed), exactly like apply_editable_dates
is, so toggling BASIC/ADVANCED takes effect immediately on the already
existing WILL/HEIR toolbars without restarting Electrum.
It is safe to call repeatedly and on either layout (horizontal toolbar
or vertical wizard): it only flips the visibility of the threshold
widget.
"""
try:
basic = self.bal_window.bal_plugin.is_basic_mode()
except Exception:
# If the mode cannot be read, keep the field visible (the safe,
# information-preserving default).
basic = False
threshold = self.widgets.get("threshold")
if threshold is not None:
# Hidden in BASIC, visible in ADVANCED.
threshold.setVisible(not basic)
def open_or_save_calendar(self): def open_or_save_calendar(self):
"""Build and save an .ics calendar file with SEPARATE reminder events. """Build and save an .ics calendar file with SEPARATE reminder events.
@@ -774,23 +948,43 @@ class WillSettingsWidget(QWidget):
""" """
now = BalCalendar.format_time(datetime.now()) now = BalCalendar.format_time(datetime.now())
# locktime = delivery deadline; threshold = start of the check-alive # locktime = delivery deadline. It is exposed by the date widget as
# period. Both are datetimes exposed by the date widgets as ``.alarm``. # ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
# transaction locktime, so the calendar uses the correct delivery date.
locktime = self.widgets["locktime"].alarm locktime = self.widgets["locktime"].alarm
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline. # BASIC vs ADVANCED reminder strategy.
days = (locktime - threshold).days #
# In ADVANCED mode the reminders are spread uniformly across the
# How many reminder events the user asked for (default 3 if unreadable). # check-alive (threshold) period, ending one day before the deadline.
try: #
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) # In BASIC mode the check-alive parameter is NOT shown nor managed by the
except Exception: # user (it stays at an arbitrary default), so spreading reminders over it
count = 3 # is meaningless. The owner asked that, in BASIC, the calendar simply
# saves the inheritance delivery date with three fixed reminders: 30 days
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always # before, 10 days before and 1 day before. We also drop any fixed offset
# ends with 1 (one day before the locktime) when >= 2 reminders fit. # that would fall in the past (a reminder before "today" is useless), so
offsets = compute_reminder_offsets(days, count) # a short-dated will still gets the reminders that are still in the
# future.
if self.bal_window.bal_plugin.is_basic_mode():
# Whole days from now until the delivery date. Fixed offsets (30, 10,
# 1 day before) are applied by basic_reminder_offsets, which also
# drops any offset that would fall in the past.
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
# ADVANCED: spread reminders over the check-alive period as before.
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminders the user asked for (default 3 if unreadable).
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception:
count = 3
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
offsets = compute_reminder_offsets(days, count)
# Per-event heir details and the shared description/summary templates. # Per-event heir details and the shared description/summary templates.
heirs_details = "\r\n".join( heirs_details = "\r\n".join(

View File

@@ -466,7 +466,17 @@ class BalWindow:
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=True, bal_window=self, task=False self.bal_plugin, update=True, bal_window=self, task=False
) )
if self.date_to_check < datetime.now().timestamp(): # SIMPLE / ADVANCED: in BASIC mode the "Check Alive" parameter must
# behave AS IF IT DID NOT EXIST. The check-alive threshold drives
# the "you are alive -> postpone the inheritance" prompt; raising
# CheckAliveError here is what triggers that postpone/invalidate
# flow. In BASIC we therefore SKIP this check entirely, so a passed
# check-alive date never forces a postpone/rewrite of the will. The
# delivery time (locktime) is unaffected and still fully enforced.
if (
not self.bal_plugin.is_basic_mode()
and self.date_to_check < datetime.now().timestamp()
):
raise CheckAliveError(self.date_to_check) raise CheckAliveError(self.date_to_check)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get()) self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
@@ -591,7 +601,15 @@ class BalWindow:
elif isinstance(e, TxFeesChangedException): elif isinstance(e, TxFeesChangedException):
message = "Txfees are changed" message = "Txfees are changed"
elif isinstance(e, HeirNotFoundException): elif isinstance(e, HeirNotFoundException):
message = "Heir not found" # Task #01b: replace the misleading "Heir not found" text.
# This branch is most often hit because the delivery date
# was anticipated, not because an heir is missing, so we use
# a clear message that covers both the DATE and the HEIRS
# cases (kept consistent with dialogs.py / the CHECK window).
message = (
"Found CHANGES to the DATE or the HEIRS,\n"
"a NEW WILL must be prepared."
)
if message: if message:
self.show_message( self.show_message(
@@ -1141,11 +1159,12 @@ class BalWindow:
base_msg = _("Downloading will-executors list...") base_msg = _("Downloading will-executors list...")
download_start = time.time() download_start = time.time()
# Upper bound shown to the user. fetch_will_executors_list tries up to # Upper bound shown to the user. Unified with every other network wait
# two endpoints, each with timeout=10 and one retry (~21s worst case), # via the single shared Willexecutors.NETWORK_DEADLINE constant (the
# so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how # user asked for one consistent 20s value everywhere instead of the old
# long they may have to wait instead of an open-ended counter. # scattered 30s/45s numbers). Showing "Xs / NETWORK_DEADLINEs" tells the
download_deadline = 45 # user how long they may have to wait instead of an open-ended counter.
download_deadline = Willexecutors.NETWORK_DEADLINE
def task(): def task():
# Heartbeat: show an elapsed-seconds counter (with the max wait made # Heartbeat: show an elapsed-seconds counter (with the max wait made
@@ -1316,6 +1335,16 @@ class BalWindow:
_settings_widget.apply_editable_dates() _settings_widget.apply_editable_dates()
except Exception as _edit_err: except Exception as _edit_err:
_logger.debug(f"apply_editable_dates error: {_edit_err}") _logger.debug(f"apply_editable_dates error: {_edit_err}")
# Re-apply BASIC/ADVANCED visibility of the Check-Alive
# field on the existing WILL/HEIR toolbars, so switching
# USER TYPE shows/hides it immediately (bug fix: it used to
# reappear only in the wizard, not on these tabs).
try:
_settings_widget.apply_user_type_visibility()
except Exception as _vis_err:
_logger.debug(
f"apply_user_type_visibility error: {_vis_err}"
)
except Exception as e: except Exception as e:
_logger.error(f"error while updating window: {e}") _logger.error(f"error while updating window: {e}")

View File

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

@@ -81,6 +81,98 @@ def test_heirs_prepare_lists():
assert isinstance(result, dict) assert isinstance(result, dict)
# ------------------------------------------------------------------ #
# ALL-DUST GUARD (prepare_lists) - owner request, v0.4.7
#
# The plugin must REFUSE to build an inheritance when EVERY heir's share is
# below the Bitcoin dust limit (HeirAmountIsDustException), but must keep
# building normally when at least one heir is valid - including the tricky
# case where the valid heir lives on a DIFFERENT locktime than the dust one.
# These three tests pin that behaviour down so it cannot silently regress.
# ------------------------------------------------------------------ #
def test_prepare_lists_all_dust_raises():
"""All heirs below the dust limit -> HeirAmountIsDustException.
Reproduces the owner's log: a very small wallet balance split between
percentage heirs gives each one a tiny share (like 214/316/3 sat), all
below the dust threshold. prepare_lists must then REFUSE to build the
"empty" inheritance and raise the exception.
NOTE: we deliberately use *percentage* heirs with a *small* balance. With
fixed amounts and a large balance the leftover funds are redistributed to
the heirs (so they would no longer be dust) - that is a different, valid
case and is covered by the other prepare_lists tests.
"""
from bal.core.heirs import HeirAmountIsDustException
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
h = Heirs.__new__(Heirs)
# Tiny balance (800 sat) split 40%/60% -> each share is far below 500.
h.update({
"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "40%", "30d"],
"b": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "60%", "30d"],
})
raised = False
try:
h.prepare_lists(800, 100, wallet)
except HeirAmountIsDustException:
raised = True
assert raised, "all-dust will must raise HeirAmountIsDustException"
def test_prepare_lists_mixed_dust_continues():
"""Some dust + at least one valid heir -> build continues normally.
The guard must NOT fire here: one heir's share is dust (a 1% slice of a
small balance), the other is a valid fixed amount, so the inheritance is
still feasible (unchanged behaviour).
"""
from bal.core.heirs import HeirAmountIsDustException
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
h = Heirs.__new__(Heirs)
h.update({
"ok": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"],
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
})
raised = False
try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException:
raised = True
assert not raised, "a mix of dust + valid heirs must NOT be blocked"
assert isinstance(result, dict) and len(result) > 0
def test_prepare_lists_multi_locktime_continues():
"""Dust heir and valid heir on DIFFERENT locktimes -> build continues.
This pins down the false-positive fix: prepare_transactions only ever sees
the lowest locktime, so the dust check MUST live in prepare_lists (which
sees ALL locktimes). A dust heir at the earlier date must not block a valid
heir at the later date.
"""
from bal.core.heirs import HeirAmountIsDustException
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
h = Heirs.__new__(Heirs)
h.update({
"early_dust": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "1%", "30d"],
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
})
raised = False
try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException:
raised = True
assert not raised, "valid heir on a later locktime must NOT be blocked"
assert isinstance(result, dict) and len(result) > 0
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Heirs static methods (pure but use Electrum constants) # Heirs static methods (pure but use Electrum constants)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,116 @@
"""
Tests for Group F (heir-change full rebuild, "Option A").
Context (bugs E/F/K): when an heir was deleted/changed and the rebuilt
inheritance transaction happened to keep the SAME txid, ``Will.update_will``
used to REUSE the old (already signed/COMPLETE) WillItem, copying only the new
heirs onto it. The downstream ``have_to_sign`` check then saw the item as
COMPLETE and reported "Nothing to do", so the new will was never signed or
broadcast.
The fix reuses the old item ONLY when the real heirs are identical. These tests
pin the behaviour of the helper ``Will._same_heirs`` that drives that decision.
Run:
source electrum/env/bin/activate
python3 tests/test_group_f_heir_change_rebuild.py
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will
# Heir entry layout (see heirs.py): [0]=address, [1]=amount, [2]=locktime.
# Extra trailing fields (e.g. real/dust amount) must NOT affect equality.
def _heir(address, amount, locktime, *extra):
return [address, amount, locktime, *extra]
def test_same_heirs_identical():
"""Identical real heirs -> True (old signed item may be reused)."""
a = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
b = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
assert Will._same_heirs(a, b) is True
def test_same_heirs_ignores_extra_fields():
"""Derived/extra fields (real amount, dust flag) do not break equality."""
a = {"alice": _heir("bc1qalice", "50%", "2026-12-01", 12345, "DUST")}
b = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
assert Will._same_heirs(a, b) is True
def test_same_heirs_deleted_heir():
"""Deleting one of two heirs -> changed -> False (must rebuild/re-sign)."""
old = {
"alice": _heir("bc1qalice", "50%", "2026-12-01"),
"bob": _heir("bc1qbob", "50%", "2026-12-01"),
}
new = {
# bob removed; alice is auto-scaled to 100% by the plugin.
"alice": _heir("bc1qalice", "100%", "2026-12-01"),
}
assert Will._same_heirs(old, new) is False
def test_same_heirs_added_heir():
"""Adding a heir -> changed -> False."""
old = {"alice": _heir("bc1qalice", "100%", "2026-12-01")}
new = {
"alice": _heir("bc1qalice", "50%", "2026-12-01"),
"carol": _heir("bc1qcarol", "50%", "2026-12-01"),
}
assert Will._same_heirs(old, new) is False
def test_same_heirs_changed_amount():
"""Same heir name but different amount -> changed -> False."""
old = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
new = {"alice": _heir("bc1qalice", "70%", "2026-12-01")}
assert Will._same_heirs(old, new) is False
def test_same_heirs_changed_address():
"""Same heir name but different destination address -> False."""
old = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
new = {"alice": _heir("bc1qOTHER", "50%", "2026-12-01")}
assert Will._same_heirs(old, new) is False
def test_same_heirs_changed_locktime():
"""Same heir but different delivery locktime -> False."""
old = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
new = {"alice": _heir("bc1qalice", "50%", "2027-06-01")}
assert Will._same_heirs(old, new) is False
def test_same_heirs_ignores_willexecutor_pseudo_heirs():
"""Reserved 'w!ll3x3c\"' pseudo-heirs are bookkeeping, not real heirs.
A difference only in the pseudo-heir entries must NOT be reported as a heir
change (the will-executor is refreshed separately in update_will).
"""
old = {
"alice": _heir("bc1qalice", "100%", "2026-12-01"),
'w!ll3x3c"server1': _heir("bc1qwe1", "0", "0"),
}
new = {
"alice": _heir("bc1qalice", "100%", "2026-12-01"),
'w!ll3x3c"server2': _heir("bc1qwe2", "0", "0"),
}
assert Will._same_heirs(old, new) is True
def test_same_heirs_empty():
"""Two empty heir maps are equal; None is treated as empty."""
assert Will._same_heirs({}, {}) is True
assert Will._same_heirs(None, {}) is True
assert Will._same_heirs(None, None) is True
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__, "-v"]))

View File

@@ -0,0 +1,79 @@
"""
Tests for the BASIC-mode calendar reminders.
Context: in BASIC mode the check-alive parameter is hidden and not managed by
the user, so calendar reminders cannot be spread over the check-alive period as
they are in ADVANCED mode. Instead the calendar uses three fixed reminders -
30, 10 and 1 day before the inheritance delivery date - and drops any reminder
that would fall in the past.
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
drives that decision.
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
the other GUI tests.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_g_basic_calendar.py -q
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
basic_reminder_offsets)
def test_basic_offsets_all_future():
"""A far delivery date keeps all three fixed reminders (30, 10, 1)."""
assert basic_reminder_offsets(365) == [30, 10, 1]
def test_basic_offsets_exactly_30_days():
"""Exactly 30 days away: the 30-day reminder is still valid (<=)."""
assert basic_reminder_offsets(30) == [30, 10, 1]
def test_basic_offsets_drops_30_when_too_close():
"""20 days away: the 30-day reminder is in the past and is dropped."""
assert basic_reminder_offsets(20) == [10, 1]
def test_basic_offsets_only_one_left():
"""5 days away: only the 1-day reminder remains."""
assert basic_reminder_offsets(5) == [1]
def test_basic_offsets_empty_when_deadline_today():
"""Delivery date less than a day away: no reminder fits."""
assert basic_reminder_offsets(0) == []
def test_basic_offsets_empty_when_negative():
"""A past delivery date (negative days) yields no reminders."""
assert basic_reminder_offsets(-10) == []
def test_basic_offsets_are_a_subset_of_the_fixed_set():
"""Whatever the horizon, results are always a subset of the fixed offsets."""
for horizon in (-1, 0, 1, 9, 10, 11, 29, 30, 100):
result = basic_reminder_offsets(horizon)
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
# Always sorted descending (earliest reminder first) and every offset >= 1.
assert result == sorted(result, reverse=True)
assert all(off >= 1 for off in result)
if __name__ == "__main__":
test_basic_offsets_all_future()
test_basic_offsets_exactly_30_days()
test_basic_offsets_drops_30_when_too_close()
test_basic_offsets_only_one_left()
test_basic_offsets_empty_when_deadline_today()
test_basic_offsets_empty_when_negative()
test_basic_offsets_are_a_subset_of_the_fixed_set()
print("all BASIC calendar reminder tests passed")