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

@@ -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