core+gui: name the real cause of a failed build instead of guessing #7

Merged
bitcoinafterlife merged 1 commits from ui/build-will-error-messages into main 2026-09-09 12:31:28 +00:00
4 changed files with 377 additions and 59 deletions
Showing only changes of commit 05002dc0a5 - Show all commits

View File

@@ -2908,3 +2908,88 @@ renumber the grid rows.
- Full test suite: 438 passed (unchanged). - Full test suite: 438 passed (unchanged).
**Outcome:** DONE. **Outcome:** DONE.
---
## 57. Name the real cause of a failed build instead of guessing
**Date:** 2026-09-04
**Goal (owner request):** the "Building Will" report was hard to read and often
misleading.
1. The long "could not build the will" block was printed entirely in amber
(`COLOR_WARNING`), which the owner reported as barely legible.
2. Whenever the build produced nothing, the dialog printed a FIXED list of
three "possible reasons" (low balance / dust shares / check-alive later than
the delivery date) regardless of what had actually happened. In a case
reproduced from the owner's log all three were false, and the real cause
(no delivery date left to build) was not even in the list.
3. The "Checking your will" row had the same problem: the single sentence
"Found CHANGES to the DATE or the HEIRS" was shown for five different
situations, including one where it is plainly wrong - funds received, where
neither the date nor the heirs changed.
**What changed:**
- `bal/core/heirs.py`
- `Heirs.__init__` / `buildTransactions`: new `last_build_error` attribute
recording WHY a build produced no transaction. Reset at the start of every
build, and set at each path that previously returned empty with no
explanation at all: `NO_HEIRS`, `NO_UTXO`, `NO_WILLEXECUTOR_USABLE`,
`NO_FUTURE_DATE`, `WILLEXECUTOR_FEE`, `WILLEXECUTOR_FEE_TOO_HIGH`,
`TX_BUILD_FAILED`, `WILLEXECUTOR_TX_ERROR`.
- Added a `processed_willexecutors` counter so that "the loop skipped every
will-executor" - which returned silently, with no log line whatsoever - is
told apart from "we tried and the build failed".
- Fixed a latent crash in the `prepare_transactions` exception handler. It
read `e.heirname` in order to auto-deselect the offending will-executor,
but NOTHING in the plugin sets that attribute any more (leftover from an
older exception design), so the lookup itself raised AttributeError and the
inner `except Exception: raise` re-raised THAT, aborting the whole build
with a confusing secondary error instead of the real one. The handler now
records `WILLEXECUTOR_TX_ERROR`, logs the actual exception together with
the will-executor it happened on, and moves on to the next one - which is
what the original code was clearly trying to do.
- `bal/gui/qt/dialogs.py`
- New `msg_alert()`: an amber warning sign (U+26A0, written as a numeric HTML
entity so the source stays ASCII) followed by text in the theme's default
colour. Colour is what ATTRACTS attention, not what is read, so it is kept
on the sign alone; the message body stays readable and still works under
the dark theme, where a hard-coded black would disappear.
- New `_build_failure_message()`: maps `last_build_error` to ONE specific
sentence. When the code is missing or unrecognised it SAYS the cause could
not be determined and lists what to check, instead of asserting three
guesses as if they were the only possibilities.
- New `_check_failure_message()`: replaces the single "Found CHANGES to the
DATE or the HEIRS" line with seven precise messages, reusing the detail the
exceptions already carry (heir name, will-executor URL, old and new fee
rate). The two plain `NotCompleteWillException` cases are told apart
STRUCTURALLY (raised with no argument vs. with one), not by matching
message text, which would be fragile. No new exception classes were added
(owner request).
- Added a dedicated `except BalanceTooLowException` handler. The exception
already carried the balance, the fees and the dust threshold, but was
falling through to the generic handler, which printed the raw technical
string in red and re-raised. It now shows the real figures.
- "Checking variables" row: `No Heirs` now uses `msg_alert()`. The
"Check Alive Threshold Passed" message deliberately STAYS red
(`COLOR_ERROR`) because it is the more urgent situation (owner request).
- `bal/gui/qt/common.py`
- Re-export `BalanceTooLowException` from `core.heirs` so the Qt layer can
catch it.
**Verification:**
- `py_compile` clean on all 44 files of the package.
- The real `msg_alert`, `_build_failure_message` and `_check_failure_message`
were extracted from the source via AST and executed against every reason code
and every exception type, with the exception hierarchy rebuilt from
`will.py`: 9 build cases and 8 check cases all produce the intended text.
- NOT RUN: the official test suite. The machine used for this task (Windows)
has no importable `electrum` module, so `tests/` could not be executed.
- Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`,
`WILLEXECUTOR_FEE` and `No Heirs` were all confirmed on screen.
**Outcome:** DONE.

View File

@@ -363,6 +363,10 @@ class Heirs(dict, Logger):
Logger.__init__(self) Logger.__init__(self)
self.db = wallet.db self.db = wallet.db
self.wallet = wallet self.wallet = wallet
# Reason code explaining why the last buildTransactions() produced no
# transaction (None when the last build succeeded or never ran). See
# buildTransactions for the list of codes and why they exist.
self.last_build_error = None
d = self.db.get("heirs", {}) d = self.db.get("heirs", {})
try: try:
self.update(d) self.update(d)
@@ -630,6 +634,20 @@ class Heirs(dict, Logger):
def buildTransactions( def buildTransactions(
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0 self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
): ):
# Reset the diagnostic reason at the start of every build attempt.
#
# WHY: when the build produced nothing, the GUI used to show a fixed
# list of three "possible reasons" (low balance / dust shares /
# check-alive after the delivery date). In practice the real cause is
# often NONE of those three - several code paths below simply return
# an empty result with no explanation at all, so the user was shown
# three guesses that were all wrong. Each such path now records WHY
# it gave up, and BalBuildWillDialog names the actual cause.
#
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
# WILLEXECUTOR_TX_ERROR.
self.last_build_error = None
_before = list(self.keys()) _before = list(self.keys())
Heirs._validate(self, persist=False) Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self] _removed = [k for k in _before if k not in self]
@@ -644,6 +662,7 @@ class Heirs(dict, Logger):
", ".join(_removed), ", ".join(_removed),
) )
if len(self) <= 0: if len(self) <= 0:
self.last_build_error = "NO_HEIRS"
_logger.info("while building transactions there was no heirs") _logger.info("while building transactions there was no heirs")
return return
balance = 0.0 balance = 0.0
@@ -660,12 +679,18 @@ class Heirs(dict, Logger):
len_utxo_set += 1 len_utxo_set += 1
available_utxos.append(utxo) available_utxos.append(utxo)
if len_utxo_set == 0: if len_utxo_set == 0:
self.last_build_error = "NO_UTXO"
_logger.info("no usable utxos") _logger.info("no usable utxos")
return return
j = -2 j = -2
willexecutorsitems = list(willexecutors.items()) willexecutorsitems = list(willexecutors.items())
willexecutorslen = len(willexecutorsitems) willexecutorslen = len(willexecutorsitems)
alltxs = {} alltxs = {}
# Counts how many will-executors were actually PROCESSED (i.e. passed
# the is_selected/is_valid filter below and reached the build loop).
# If it stays 0 the loop silently skipped every single one, which is a
# distinct failure from "we tried and the build failed".
processed_willexecutors = 0
while True: while True:
j += 1 j += 1
if j >= willexecutorslen: if j >= willexecutorslen:
@@ -682,6 +707,7 @@ class Heirs(dict, Logger):
url = willexecutor = None url = willexecutor = None
else: else:
break break
processed_willexecutors += 1
fees = {} fees = {}
i = 0 i = 0
txs = {} txs = {}
@@ -699,9 +725,11 @@ class Heirs(dict, Logger):
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except WillExecutorFeeException: except WillExecutorFeeException:
self.last_build_error = "WILLEXECUTOR_FEE"
i = 10 i = 10
continue continue
except WillExecutorFeeTooHighException: except WillExecutorFeeTooHighException:
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
i = 10 i = 10
continue continue
if locktimes: if locktimes:
@@ -710,19 +738,33 @@ class Heirs(dict, Logger):
locktimes, available_utxos[:], fees, wallet locktimes, available_utxos[:], fees, wallet
) )
if not txs: if not txs:
self.last_build_error = "TX_BUILD_FAILED"
return {} return {}
except Exception as e: except Exception as e:
# An unexpected failure while assembling the
# transactions for THIS will-executor.
#
# WHY THIS CHANGED: the previous code read
# ``e.heirname`` here, in order to auto-deselect the
# will-executor blamed by the exception. NOTHING in
# the plugin sets that attribute any more (it is a
# leftover from an older exception design), so the
# lookup itself raised AttributeError, and the inner
# ``except Exception: raise`` re-raised THAT - aborting
# the whole build with a confusing secondary error
# instead of the real one. We now record the reason,
# log the actual exception together with the
# will-executor it happened on, and simply move on to
# the next one, which is what the original code was
# clearly trying to do.
self.last_build_error = "WILLEXECUTOR_TX_ERROR"
_logger.error( _logger.error(
f"build transactions: error preparing transactions: {e}" "build transactions: error preparing transactions "
) "for will-executor %s: %r",
try: (willexecutor or {}).get("url", "(none)"),
if "w!ll3x3c" in e.heirname: e,
Willexecutors.is_selected(
e.heirname[len("w!ll3x3c") :], False
) )
break break
except Exception:
raise
total_fees = 0 total_fees = 0
total_fees_real = 0 total_fees_real = 0
total_in = 0 total_in = 0
@@ -746,12 +788,26 @@ class Heirs(dict, Logger):
if i >= 10: if i >= 10:
break break
else: else:
self.last_build_error = "NO_FUTURE_DATE"
_logger.info( _logger.info(
f"no locktimes for willexecutor {willexecutor} skipped" f"no locktimes for willexecutor {willexecutor} skipped"
) )
break break
alltxs.update(txs) alltxs.update(txs)
# Every will-executor was skipped by the is_selected/is_valid filter
# (or the list was empty) and no "no will-executor" build was allowed,
# so the loop above never even attempted a build. This path used to
# return silently with no log line at all, which is exactly the case
# the owner hit: the dialog then blamed balance/dust/check-alive, none
# of which was true.
if not alltxs and processed_willexecutors == 0:
self.last_build_error = "NO_WILLEXECUTOR_USABLE"
_logger.info(
"no usable will-executor: all %d skipped (not selected or not valid)",
willexecutorslen,
)
return alltxs return alltxs
def get_transactions( def get_transactions(

View File

@@ -108,6 +108,7 @@ from ...core.heirs import (
HEIR_DUST_AMOUNT, HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT, HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX, OP_RETURN_PREFIX,
BalanceTooLowException,
HeirAmountIsDustException, HeirAmountIsDustException,
Heirs, Heirs,
WillExecutorFeeTooHighException, WillExecutorFeeTooHighException,

View File

@@ -28,6 +28,7 @@ from .common import (
AmountException, AmountException,
Any, Any,
BalTimestamp, BalTimestamp,
BalanceTooLowException,
BestEffortRequestFailed, BestEffortRequestFailed,
Buttons, Buttons,
Callable, Callable,
@@ -690,12 +691,19 @@ class BalBuildWillDialog(BalDialog):
_logger.debug( _logger.debug(
"during phase1 CAE: {}, Continue to invalidate".format(cae) "during phase1 CAE: {}, Continue to invalidate".format(cae)
) )
self.msg_set_status("Checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR) self.msg_set_status(
"Checking variables", varrow,
"Check Alive Threshold Passed: you have to Invalidate "
"your old Will",
self.COLOR_ERROR,
)
else: else:
raise cae raise cae
return None, tx return None, tx
except NoHeirsException: except NoHeirsException:
self.msg_set_status("Checking variables", varrow,"No Heirs",self.COLOR_ERROR) self.msg_set_status(
"Checking variables", varrow, self.msg_alert("No Heirs")
)
return "no_heirs", None return "no_heirs", None
except Exception as e: except Exception as e:
raise e raise e
@@ -811,61 +819,29 @@ class BalBuildWillDialog(BalDialog):
_("Inheritance in mempool (waiting confirmation)"), _("Inheritance in mempool (waiting confirmation)"),
self.COLOR_WARNING, self.COLOR_WARNING,
) )
if isinstance(e, HeirChangeException): # All of these situations used to collapse into the SAME sentence
message = _("Heirs changed:") # ("Found CHANGES to the DATE or the HEIRS, a NEW WILL must be
elif isinstance(e, WillExecutorNotPresent): # prepared"), shown for five genuinely different causes - and
message = _("Will-Executor not present") # plainly WRONG for the most common one, receiving funds, where
elif isinstance(e, WillexecutorChangeException): # neither the date nor the heirs changed. _check_failure_message
message = _("Will-Executor changed") # names the real cause using the detail each exception already
elif isinstance(e, TxFeesChangedException): # carries (heir name, will-executor URL, old/new fee rate).
message = _("Txfees are changed") message = self._check_failure_message(e)
elif isinstance(e, HeirNotFoundException):
# 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}") _logger.debug(f"message: {message}")
self.msg_set_checking(message) self.msg_set_checking(message)
else:
# 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()
try: try:
txs = self.bal_window.build_will() txs = self.bal_window.build_will()
if not txs: if not txs:
# The message now names the ACTUAL reason the build gave
# up (recorded by Heirs.buildTransactions) instead of
# listing three fixed guesses that were frequently all
# wrong. msg_alert keeps the warning sign coloured and the
# text in the default colour so it stays readable.
self.msg_set_building( self.msg_set_building(
_( self.msg_alert(self._build_failure_message())
"Could not build the will ! Possible reasons:\n"
"1- the Balance of wallet is too low to cover the "
"fees for miners and will executors,\n"
"2- the Heirs' shares are below the minimum (Dust "
"UTXO, less than 546 Satoshi),\n"
"3- the Check Alive Date/Time is later than the "
"delivery time (it must be earlier),\n"
"Skipped"
),
# Orange (warning) instead of red (error): an empty
# wallet after the inheritance was executed is a NORMAL
# situation, not a failure, so the colour should not
# alarm the user (owner request).
color=self.COLOR_WARNING,
) )
return False, None return False, None
@@ -950,6 +926,28 @@ class BalBuildWillDialog(BalDialog):
) )
return False, None return False, None
except BalanceTooLowException as e:
# The core DOES detect this precisely and carries the numbers,
# but the exception was never caught here: it fell through to
# the generic handler below, which printed the raw technical
# string in red and re-raised. Show the real figures instead.
self.msg_set_building(
self.msg_alert(
_(
"Wallet balance is too low: {} satoshi available, "
"but the miner and will-executor fees need {} "
"satoshi (the minimum usable amount is {} "
"satoshi). Add funds, or select fewer "
"will-executors."
).format(
int(e.balance), int(e.fees), int(e.dust_threshold)
)
+ "\n\n"
+ _("Skipped")
)
)
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))
raise e raise e
@@ -2009,6 +2007,168 @@ class BalBuildWillDialog(BalDialog):
_logger.debug(f"_executed_inheritance_status error: {_err}") _logger.debug(f"_executed_inheritance_status error: {_err}")
return "MEMPOOL" if has_mempool else None return "MEMPOOL" if has_mempool else None
def _check_failure_message(self, e):
"""Return a precise explanation of why the will is no longer coherent.
``Will.is_will_valid`` raises NotCompleteWillException (or one of its
subclasses) when the stored will stops matching the wallet, the heirs
or the will-executors. Those exceptions ALREADY carry the useful
detail - the heir name, the will-executor URL, the old and new fee
rate - but this dialog used to discard all of it and print the same
sentence, "Found CHANGES to the DATE or the HEIRS", for every case.
That was not merely vague, it was WRONG for the most common situation:
when the wallet simply receives new funds, neither the date nor the
heirs changed, yet the user was sent hunting for edits never made.
NOTE: no new exception classes were added for this (owner request).
Every case below is told apart using only what the core already
raises today.
"""
# Subclasses first - they are all NotCompleteWillException.
if isinstance(e, TxFeesChangedException):
# The core raises TxFeesChangedException(f"{tx_fees}: {w.tx_fees}"),
# i.e. "current: stored". Show both rates when they can be read,
# and fall back to a plain sentence if that format ever changes.
try:
now_fee, old_fee = [p.strip() for p in str(e).split(":", 1)]
return _(
"Miner fee rate changed (the will was built with {} "
"sat/byte, now it is {}): a new will must be prepared."
).format(old_fee, now_fee)
except Exception:
return _(
"The miner fee rate changed: a new will must be prepared."
)
if isinstance(e, WillExecutorNotPresent):
return _(
'Will-executor "{}" is not covered by the current will: '
"a new will must be prepared."
).format(str(e))
if isinstance(e, NoWillExecutorNotPresent):
return _(
"Backup mode is enabled but the will has no backup "
"transaction: a new will must be prepared."
)
if isinstance(e, HeirNotFoundException):
# Raised when an heir was added, removed, or had its delivery date
# changed. We deliberately do NOT try to tell those three apart
# (owner request: too fine-grained); naming the heir is what makes
# the message actionable.
return _(
'Heir "{}" is not covered by the current will (it was added '
"or removed, or its delivery date changed): a new will must "
"be prepared."
).format(str(e))
# Kept for completeness: nothing in the plugin raises these two today,
# but they ARE NotCompleteWillException subclasses, so should future
# code raise them they get a sensible message rather than the fallback.
if isinstance(e, HeirChangeException):
return _("The heirs changed: a new will must be prepared.")
if isinstance(e, WillexecutorChangeException):
return _("A will-executor changed: a new will must be prepared.")
# A plain NotCompleteWillException. The core raises it in exactly two
# places, told apart STRUCTURALLY (not by matching message text, which
# would be fragile): with no argument when the will holds no valid
# transaction, and with one argument when a wallet utxo is not
# included in the will.
if type(e) is NotCompleteWillException:
if e.args:
return _(
"The wallet contains funds that the current will does not "
"cover yet: a new will must be prepared."
)
return _(
"The will contains no valid transaction: a new will must be "
"prepared."
)
# An unrecognised subclass: say so honestly instead of guessing.
return _(
"The will is no longer coherent and must be rebuilt; the exact "
"reason could not be determined."
)
def _build_failure_message(self):
"""Return a plain-language explanation of why the will was not built.
``Heirs.buildTransactions`` records a reason code in
``last_build_error`` every time it gives up (the codes are listed in
that method). Here we turn that code into ONE specific sentence
telling the user what to fix.
WHY: this dialog used to print the same three "possible reasons"
(low balance / dust shares / check-alive after the delivery date)
whenever the build returned nothing. The owner hit a real case where
all three were false - the actual cause was that no will-executor was
usable, which the list did not even mention - so the message actively
misled. When the reason is unknown we now SAY that it is unknown and
list what to check, instead of asserting three guesses as if they were
the only possibilities.
"""
reason = None
try:
reason = getattr(self.bal_window.heirs, "last_build_error", None)
except Exception as _err:
# A diagnostic must never break the report it is explaining.
_logger.debug(f"_build_failure_message: {_err}")
messages = {
"NO_HEIRS": _(
"No heirs: add at least one heir before building the will."
),
"NO_UTXO": _(
"The wallet has no spendable funds, so no inheritance "
"transaction can be created."
),
"NO_WILLEXECUTOR_USABLE": _(
"No usable will-executor: none of the servers in the list is "
"both selected and valid. Open the will-executor settings, "
"select at least one server and check that it is reachable."
),
"NO_FUTURE_DATE": _(
"No delivery date left to build: every heir's date is already "
"covered by the existing will. Choose a later delivery date, "
"or change an heir's date."
),
"WILLEXECUTOR_FEE": _(
"The amount to send must cover the miner fees plus this "
"will-executor's fee, and the wallet balance is not enough: "
"select cheaper will-executors, or add funds to the wallet."
),
"WILLEXECUTOR_FEE_TOO_HIGH": _(
"A will-executor asks for more than the maximum fee you "
"allowed: raise the maximum fee in the settings, or select a "
"cheaper will-executor."
),
"TX_BUILD_FAILED": _(
"The inheritance transactions could not be assembled from the "
"available funds (the balance may not cover the miner fees)."
),
"WILLEXECUTOR_TX_ERROR": _(
"An unexpected error stopped the transactions being prepared "
"for a will-executor, so it was skipped. Try again, or select "
"a different will-executor."
),
}
if reason in messages:
return messages[reason] + "\n\n" + _("Skipped")
return (
_(
"Could not build the will, and the exact cause could not be "
"determined. Please check that:\n"
"- the wallet balance covers the miner and will-executor fees,\n"
"- each heir's share is above the minimum (dust limit),\n"
"- the Check Alive date is EARLIER than the delivery date,\n"
"- at least one will-executor is selected and reachable."
)
+ "\n\n"
+ _("Skipped")
)
def msg_set_checking(self, status="Waiting", row=None): def msg_set_checking(self, status="Waiting", row=None):
row = self.check_row if row is None else row row = self.check_row if row is None else row
self.check_row = self.msg_set_status(_("Checking your will"), row, status) self.check_row = self.msg_set_status(_("Checking your will"), row, status)
@@ -2052,6 +2212,22 @@ class BalBuildWillDialog(BalDialog):
# Results are shown in bold (see msg_error). # Results are shown in bold (see msg_error).
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_WARNING, e) return "<font color='{}'><b>{}</b></font>".format(self.COLOR_WARNING, e)
def msg_alert(self, e):
"""Amber warning sign followed by text in the theme's default colour.
WHY: long warnings printed entirely in amber (COLOR_WARNING) are hard
to read - the owner reported the multi-line "could not build the will"
block as barely legible. Colour is only needed to ATTRACT attention,
not to be read, so we keep it on the "warning sign" character alone and
let the message body inherit Electrum's normal text colour. That also
keeps it readable under the dark theme, where a hard-coded black would
disappear. U+26A0 is written as a numeric HTML entity so the source
file stays pure ASCII; QLabel renders it as rich text.
"""
return "<font color='{}'>&#9888;</font> <b>{}</b>".format(
self.COLOR_WARNING, e
)
def msg_set_status(self, msg, row=None, status=None, color=None): def msg_set_status(self, msg, row=None, status=None, color=None):
# The left "state" label keeps its normal weight; only the right-side # The left "state" label keeps its normal weight; only the right-side
# result (``status``) is rendered in bold so it is easy to read at a # result (``status``) is rendered in bold so it is easy to read at a