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

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

View File

@@ -554,6 +554,43 @@ class Heirs(dict, Logger):
locktimes[locktime] = {key: value}
else:
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
def is_perc(self, key):

View File

@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
"""
_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.
default_app = {
@@ -190,11 +190,31 @@ class BalPlugin(BasePlugin):
# most one event per available day.
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_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", 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(
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
)
@@ -315,6 +335,16 @@ class BalPlugin(BasePlugin):
will_settings["locktime"] = defaults['locktime']
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
def default_will_settings():
"""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)
@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
def update_will(old_will, new_will):
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_we = new_will[oid].we
new_will[oid] = old_will[oid]
new_will[oid].heirs = new_heirs
new_will[oid].we = new_we
# OPTION A (heir-change full rebuild, user-approved):
#
# 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
else:

View File

@@ -30,6 +30,14 @@ from .plugin_base import BalPlugin
# block the UI.
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
# 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.
@@ -43,7 +51,8 @@ PUSH_RETRY_SLEEP = 1
# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once
# it elapses we stop waiting for the still-pending servers, mark them as
# "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
# each will-executor still holds the transaction. Like the broadcast path, the
@@ -53,7 +62,8 @@ PUSH_GLOBAL_DEADLINE = 30
CHECK_TIMEOUT = 8
CHECK_MAX_RETRIES = 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__)
@@ -68,6 +78,7 @@ class Willexecutors:
# importing module-level names. Single source of truth: the module
# constants defined above.
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT
NETWORK_DEADLINE = NETWORK_DEADLINE
PUSH_TIMEOUT = PUSH_TIMEOUT
PUSH_MAX_RETRIES = PUSH_MAX_RETRIES
PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP

View File

@@ -62,7 +62,8 @@ from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
# --- Core (GUI-free) logic layer ---
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.will import (AmountException, HeirChangeException,
HeirNotFoundException, NoHeirsException,

View File

@@ -127,6 +127,21 @@ class BalWizardDialog(BalDialog):
def on_next_we(self):
close_window = BalBuildWillDialog(self.bal_window)
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.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()
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:
for we in self.bal_window.willexecutors:
if self.bal_window.willexecutors[we]["status"] == 200:
self.bal_window.willexecutors[we]["selected"] = True
wedict = self.bal_window.willexecutors[we]
responded = str(wedict.get("status", "")) == "200"
wedict["selected"] = responded
Willexecutors.save(
self.bal_window.bal_plugin, self.bal_window.willexecutors
)
@@ -502,10 +537,35 @@ class BalBuildWillDialog(BalDialog):
self.bal_window = bal_window
self.bal_plugin = bal_window.bal_plugin
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.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.vbox.addWidget(self.qwidget, 1)
self.labelsbox = QVBoxLayout(self.qwidget)
self.setMinimumWidth(600)
self.setMinimumHeight(100)
@@ -519,6 +579,18 @@ class BalBuildWillDialog(BalDialog):
# Manual next-steps hint (Sign / Broadcast) shown to the user after the
# dialog finishes; None when nothing is left to do.
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._stopping = False
self.thread = TaskThread(self)
@@ -592,12 +664,22 @@ class BalBuildWillDialog(BalDialog):
self.bal_window.check_will()
self.msg_set_checking(self.msg_ok())
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")
self.msg_set_checking("Expired")
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
return None, Will.invalidate_will(
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
)
return "invalidate_classic", None
except WillPostponedException as e:
# An already signed/sent will is being postponed. Like an expired
# will, the previously committed coins must be invalidated on-chain
@@ -628,12 +710,30 @@ class BalBuildWillDialog(BalDialog):
elif isinstance(e, TxFeesChangedException):
message = _("Txfees are changed")
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:
_logger.debug(f"message: {message}")
self.msg_set_checking(message)
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:
self.msg_set_building()
@@ -647,10 +747,7 @@ class BalBuildWillDialog(BalDialog):
return False, None
self.bal_window.check_will()
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")
self.msg_set_building(self.msg_ok())
self._build_success_report()
except WillExecutorNotPresent:
self.msg_set_status(
_("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR
@@ -683,21 +780,77 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_building(self.msg_warning(e))
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:
self.msg_set_building(self.msg_error(e))
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):
heirs = self.bal_window.willitems[wid].heirs
for hid, heir in heirs.items():
if "DUST" in str(heir[HEIR_REAL_AMOUNT]):
self.msg_set_status(
f"{hid},{heir[HEIR_DUST_AMOUNT]} is DUST",
None,
f"Excluded from will {wid}",
self.COLOR_WARNING,
)
# Keep the first dust amount seen for this heir; it is the
# same share in every will-executor copy of the will.
dust_heirs.setdefault(hid, heir[HEIR_DUST_AMOUNT])
for hid, dust_amount in dust_heirs.items():
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
for wid in Will.only_valid(self.bal_window.willitems):
@@ -706,6 +859,116 @@ class BalBuildWillDialog(BalDialog):
break
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):
self.bal_window.update_all()
pass
@@ -733,12 +996,34 @@ class BalBuildWillDialog(BalDialog):
try:
tx.add_info_from_wallet(self.bal_window.wallet)
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.msg_set_invalidating(self.msg_ok())
if not txid:
_logger.debug(f"should not be none txid: {txid}")
except TxBroadcastError as e:
_logger.error(f"fail to broadcast transaction:{e}")
@@ -909,7 +1194,19 @@ class BalBuildWillDialog(BalDialog):
if tx:
if tx.is_complete():
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:
raise Exception("tx not complete")
else:
@@ -946,46 +1243,74 @@ class BalBuildWillDialog(BalDialog):
# is safe. We then stop and close the wizard.
if self.have_to_sign == "invalidate_classic":
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
# 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
# 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.
# before the new one can be used.
#
# The robust solution is to NOT auto-open any window here. Instead we
# close the wizard and show a clear instruction telling the user to
# run "Tools -> Invalidate" themselves. That menu path is already
# known to work perfectly (its transaction window always stays in
# front, because no other window is closing at the same time), and
# it also makes the user consciously aware that they are performing a
# deliberate, important action (invalidating their old will).
# We make the CHECK button and the WIZARD behave IDENTICALLY:
# 1. show a WARNING popup (no "Tools -> Invalidate" wording);
# 2. AUTOMATICALLY open Electrum's classic transaction dialog via
# BalWalletWindow.invalidate_will() (the same code used by the
# Tools -> Invalidate menu). That path already sets the
# "BAL Invalidate transaction" history label - which fixes
# task #03 (the label was previously missing on the automatic
# path).
#
# Close the wizard first so the instruction popup is the only window
# left, then show the guidance message.
# Window-stacking note (history): opening the transaction window
# 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.bal_window.show_message(
_(
"Your will has expired and must be invalidated before it "
"can be rebuilt.\n\n"
"Please use the top-right menu Tools -> Invalidate to "
"invalidate your old will: a transaction window will open "
"where you can sign and broadcast the invalidation.\n\n"
"can be rebuilt.\n"
"A transaction window will now open:\n"
"please SIGN and then BROADCAST it to invalidate your old "
"will.\n"
"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
_logger.debug("have to sign {}".format(self.have_to_sign))
password = None
if self.have_to_sign is None:
_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()
# need to sign invalidate and restart phase 1
@@ -1017,6 +1342,26 @@ class BalBuildWillDialog(BalDialog):
return
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(
_("Sign your will"), parent=self
)
@@ -1307,8 +1652,18 @@ class BalBuildWillDialog(BalDialog):
full_text = "<br><br>".join(self.labels).replace("\n", "<br>")
self.message_label.setText(full_text)
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())
# Re-assert the bottom position after the resize/relayout settled.
scrollbar.setValue(scrollbar.maximum())
def get_text(self):
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")
)
)
# Tooltip so the icon is self-explanatory when hovered.
refresh.setToolTip(_("Check"))
# Tooltip so the icon is self-explanatory when hovered. "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)
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).
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
# variables so the "Reset" button (Group C / C4b) can refresh the
# displayed values after resetting the underlying config.
@@ -447,6 +473,21 @@ class Plugin(BalPlugin):
# Reset/support button row (bottom). Assigning ``QGridLayout(d)`` would
# have made the grid the dialog's only layout, leaving no room for them.
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(
grid,
"Hide Replaced",
@@ -475,7 +516,7 @@ class Plugin(BalPlugin):
)
add_widget(
grid,
"Editable dates",
"Panel editable Date and Fee",
heir_editable_dates,
4,
(
@@ -485,14 +526,38 @@ class Plugin(BalPlugin):
"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(
grid,
"Number of reminders",
heir_num_reminders,
5,
6,
(
"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 "
"always fall before the delivery deadline.\n"
"If the period is shorter than the requested number, at most "
@@ -503,7 +568,7 @@ class Plugin(BalPlugin):
grid,
"Event summary",
edit_event_summary,
6,
7,
(
"Default message to be used in event summary\n"
"Variables:\n"
@@ -516,7 +581,7 @@ class Plugin(BalPlugin):
grid,
"Event description",
edit_event_description,
7,
8,
(
"Default message to be used in event description\n"
"Variables:\n"
@@ -527,21 +592,6 @@ class Plugin(BalPlugin):
)
#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(
# grid,
# "Ping Willexecutors",
@@ -584,6 +634,7 @@ class Plugin(BalPlugin):
# Map each config object to the widget that displays it, so we can
# both reset the stored value and update what the user sees.
resets = [
(self.USER_TYPE, user_type_combo, "user_type"),
(self.HIDE_REPLACED, heir_hide_replaced, "check"),
(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"),
(self.AUTO_SIGN, heir_auto_sign, "check"),
@@ -607,6 +658,11 @@ class Plugin(BalPlugin):
widget.setText(cfg.default)
elif kind == "text":
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
# editable-dates state is not in this list, but hide filters are)
# reflects the reset values.
@@ -638,6 +694,9 @@ class Plugin(BalPlugin):
# Outer layout: warning (top) -> settings grid -> bottom button row.
outer = QVBoxLayout(d)
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(bottom_row)

View File

@@ -75,6 +75,34 @@ def compute_reminder_offsets(days, count):
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):
doubleClicked = pyqtSignal()
@@ -256,6 +284,13 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
1: self.locktime_date_e,
}
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
if not default_locktime:
default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field]
@@ -264,6 +299,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
default_index = 1
except Exception:
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))
help_button=HelpButton(self.help_text)
help_button.setText(self.label_text)
@@ -290,6 +329,11 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
self.set_value(default_locktime)
self.current_value=default_locktime
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:
hbox.addWidget(w)
@@ -594,13 +638,19 @@ class LockTimeWidget(BalTimeEditWidget):
"<b>DELIVERY TIME</b><br><br>"
"Set Locktime for transactions.<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>"
" - 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>"
)
label_text = "🚛"
#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"
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["locktime"].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.on_locktime_change()
self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self)
@@ -656,53 +715,135 @@ class WillSettingsWidget(QWidget):
box.addWidget(self.calendar_button)
box.addWidget(self.widgets["baltx_fees"])
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,
# 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
# rows above).
#
# IMPORTANT: the leading icons keep their ORIGINAL size. The icons
# are HelpButtons, which already pin themselves to a fixed width
# (2.2 * char_width_in_lineedit()); we must NOT widen them, otherwise
# they look oversized compared with the original toolbar layout. We
# only need to (1) align the calendar row's left edge with the icons'
# original width and (2) cap every row to the date-row width.
# ----------------------------------------------------------------- #
# Vertical layout (the "Build your will" wizard) - Layout H. #
# #
# User requirements (allegato4): #
# * the leading ICONS (delivery time, calendar, fee and - in #
# ADVANCED mode - check-alive) must be aligned one under the #
# other on the left; #
# * the editable FIELDS must all start from the SAME x position, #
# just to the right of their icon; #
# * the fee field must be WIDER (its up/down arrows were covering #
# the digits) - not a tiny box. #
# #
# 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"]
threshold_w = self.widgets["threshold"]
fees_w = self.widgets["baltx_fees"]
# Original icon width (HelpButton's own fixed width); used only to
# offset the calendar button so its field starts under the others.
icon_w = locktime_w.prefix_widget.sizeHint().width()
# Common row width = natural width of the date rows (the reference).
row_w = max(
locktime_w.sizeHint().width(),
threshold_w.sizeHint().width(),
# Common icon width = the widest leading icon (incl. the calendar
# button). Forcing every icon to this width makes the icons line up
# 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.
icon_w = max(
locktime_w.prefix_widget.sizeHint().width(),
threshold_w.prefix_widget.sizeHint().width(),
fees_w.prefix_widget.sizeHint().width(),
self.calendar_button.sizeHint().width(),
)
for w in (locktime_w, threshold_w, fees_w):
w.setFixedWidth(row_w)
for icon in (
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
# empty spacer of the icon width (calendar field aligned with the
# date/fee fields) and cap it to the same total width as the rows
# above, so it no longer stretches to the dialog's right edge.
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)
# WIDEN the fee field (user feedback: the spin-box up/down arrows
# were covering the digits). ~8 chars leaves room for the value and
# the arrows.
fees_w.field_widget.setFixedWidth(8 * char_width_in_lineedit())
# 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(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)
# 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
# fields. Done once at creation here, and re-applied later by
# 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.
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):
"""Build and save an .ics calendar file with SEPARATE reminder events.
@@ -774,23 +948,43 @@ class WillSettingsWidget(QWidget):
"""
now = BalCalendar.format_time(datetime.now())
# locktime = delivery deadline; threshold = start of the check-alive
# period. Both are datetimes exposed by the date widgets as ``.alarm``.
# locktime = delivery deadline. It is exposed by the date widget as
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
# transaction locktime, so the calendar uses the correct delivery date.
locktime = self.widgets["locktime"].alarm
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminder events 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)
# BASIC vs ADVANCED reminder strategy.
#
# In ADVANCED mode the reminders are spread uniformly across the
# check-alive (threshold) period, ending one day before the deadline.
#
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
# user (it stays at an arbitrary default), so spreading reminders over it
# is meaningless. The owner asked that, in BASIC, the calendar simply
# saves the inheritance delivery date with three fixed reminders: 30 days
# before, 10 days before and 1 day before. We also drop any fixed offset
# that would fall in the past (a reminder before "today" is useless), so
# 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.
heirs_details = "\r\n".join(

View File

@@ -466,7 +466,17 @@ class BalWindow:
self.willexecutors = Willexecutors.get_willexecutors(
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)
self.init_heirs_to_locktime(self.bal_plugin.ENABLE_MULTIVERSE.get())
@@ -591,7 +601,15 @@ class BalWindow:
elif isinstance(e, TxFeesChangedException):
message = "Txfees are changed"
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:
self.show_message(
@@ -1141,11 +1159,12 @@ class BalWindow:
base_msg = _("Downloading will-executors list...")
download_start = time.time()
# Upper bound shown to the user. fetch_will_executors_list tries up to
# two endpoints, each with timeout=10 and one retry (~21s worst case),
# so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how
# long they may have to wait instead of an open-ended counter.
download_deadline = 45
# Upper bound shown to the user. Unified with every other network wait
# via the single shared Willexecutors.NETWORK_DEADLINE constant (the
# user asked for one consistent 20s value everywhere instead of the old
# scattered 30s/45s numbers). Showing "Xs / NETWORK_DEADLINEs" tells the
# user how long they may have to wait instead of an open-ended counter.
download_deadline = Willexecutors.NETWORK_DEADLINE
def task():
# Heartbeat: show an elapsed-seconds counter (with the max wait made
@@ -1316,6 +1335,16 @@ class BalWindow:
_settings_widget.apply_editable_dates()
except Exception as _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:
_logger.error(f"error while updating window: {e}")

View File

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