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

@@ -81,6 +81,98 @@ def test_heirs_prepare_lists():
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)
# ------------------------------------------------------------------ #

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")