core: extract GUI-free logic into bal.core (reminders/checkalive/input_rules); fix save/sign tx RLock pickle by serializing tx; keep invalid heirs in wallet on build; tb1 testnet will-executor addresses; move tests to core modules

This commit is contained in:
2026-08-05 17:17:32 -04:00
parent 0806332543
commit 95c23a4b21
22 changed files with 1516 additions and 2746 deletions

71
bal/core/checkalive.py Normal file
View File

@@ -0,0 +1,71 @@
"""
bal.core.checkalive
===================
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
"""
from datetime import datetime
from typing import Any
from .plugin_base import BalTimestamp
class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def resolve_date_to_check(
is_basic_mode: bool, will_settings: Any, now: float | None = None
) -> float:
"""Return the reference timestamp for every will-validity check.
``date_to_check`` is the single reference timestamp that EVERY downstream
check reads: the build filter, the heir count, ``check_will_expired``,
``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is.
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
will_settings: the per-wallet settings dict (``"threshold"`` key).
now: overridable clock for tests; defaults to ``datetime.now()``.
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now().timestamp())
return BalTimestamp(will_settings["threshold"]).to_timestamp()
def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool:
"""True when the Check Alive guard should fire (``CheckAliveError``).
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone or
rewrite of the will.
"""
if is_basic_mode:
return False
current = now if now is not None else datetime.now().timestamp()
return date_to_check < current

View File

@@ -630,7 +630,19 @@ class Heirs(dict, Logger):
def buildTransactions(
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
):
Heirs._validate(self)
_before = list(self.keys())
Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self]
if _removed:
# The build skips invalid heirs, but they are only dropped in memory
# (persist=False): the wallet still keeps them, so the user can fix
# or remove them deliberately instead of losing them silently.
_logger.warning(
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
"not removed): %s",
len(_removed),
", ".join(_removed),
)
if len(self) <= 0:
_logger.info("while building transactions there was no heirs")
return
@@ -877,16 +889,22 @@ class Heirs(dict, Logger):
return (address, amount, locktime)
@staticmethod
def _validate(data, timestamp_to_check=False):
def _validate(data, timestamp_to_check=False, persist=True):
for k, v in list(data.items()):
if k == "heirs":
return Heirs._validate(v, timestamp_to_check)
return Heirs._validate(v, timestamp_to_check, persist)
try:
Heirs.validate_heir(k, v, timestamp_to_check)
except Exception as e:
_logger.info(f"exception heir removed {e}")
data.pop(k)
if persist:
data.pop(k)
else:
# Drop the invalid heir in memory only, so the overridden
# Heirs.pop (which calls save()) is not triggered: a build
# must not silently delete heirs from the wallet.
dict.pop(data, k)
return data

193
bal/core/input_rules.py Normal file
View File

@@ -0,0 +1,193 @@
"""
bal.core.input_rules
====================
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
amount field normalisation.
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
Keeping them here makes them testable without Qt and lets any GUI front-end
reuse the exact same parsing rules.
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional, Tuple, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
__all__ = [
"NLOCKTIME_BLOCKHEIGHT_MAX",
"NLOCKTIME_MAX",
"NLOCKTIME_MIN",
"LockTimeEditor",
"normalize_locktime_raw_text",
"normalize_perc_amount_text",
"parse_perc_amount",
"replace_dy_suffixes",
]
class LockTimeEditor:
"""Acceptance bounds shared by the RAW and Date locktime editors.
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
live here so they can be tested and reused without Qt. Widget subclasses
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
"""
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
"""True when ``x`` (string or int) is within the allowed locktime bounds.
An empty/falsy value is accepted (the field is not yet filled in).
"""
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
"""Highest locktime timestamp accepted on this platform.
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
"""
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is
# platform-dependent). see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
def replace_dy_suffixes(text: str) -> str:
"""Strip the relative-time suffixes (d/y) from ``text``.
Only days ("d") and years ("y") are supported. The block-height suffix
("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
When the character just before ``pos`` equals ``appendix``, the text is
re-normalised so only one suffix remains.
"""
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = replace_dy_suffixes(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def normalize_locktime_raw_text(
text: str, pos: int
) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text.
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
Args:
text: the raw field text.
pos: the cursor position within ``text``.
Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
text and ``new_pos`` the adjusted cursor position.
"""
text = text.strip()
chars = "0123456789dy"
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
isdays = False
isyears = False
pos, s = _checkbdy(s, pos, "d")
pos, s = _checkbdy(s, pos, "y")
if "d" in s:
isdays = True
if "y" in s:
isyears = True
if isdays:
s = replace_dy_suffixes(s) + "d"
if isyears:
s = replace_dy_suffixes(s) + "y"
return s, isdays, isyears, pos
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
"""Sanitise the amount-or-percentage field text.
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
as a percentage (``is_perc``). At most 8 decimal digits after the point.
Args:
text: the raw field text.
decimal_point: the decimal separator character (``electrum`` uses
``DECIMAL_POINT``, locale dependent).
Returns:
``(clean, is_perc)``.
"""
text = text.strip()
chars = "0123456789%"
chars += decimal_point
s = "".join([i for i in text if i in chars])
if "%" in s:
is_perc = True
s = s.replace("%", "")
else:
is_perc = False
if decimal_point in s:
p = s.find(decimal_point)
s = s.replace(decimal_point, "")
s = s[:p] + decimal_point + s[p : p + 8]
if is_perc:
s += "%"
return s, is_perc
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
"""Parse an amount-or-percentage field text into a numeric value.
Returns ``None`` when the text cannot be parsed.
"""
try:
text = text.replace(decimal_point, ".")
text = text.replace("%", "")
return Decimal(text)
except Exception:
return None

View File

@@ -306,7 +306,10 @@ class BalPlugin(BasePlugin):
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
)
# Default will-executor servers, keyed by network.
# Default will-executor servers, keyed by network. These addresses are
# the ones currently reported by each server's <chain>/info endpoint and
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
# (bcrt1...) addresses, which are invalid on those networks.
self.WILLEXECUTORS = BalConfig(
config,
"bal_willexecutors",
@@ -325,7 +328,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True,
}
},
@@ -334,7 +337,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"selected": True,
}
},

228
bal/core/reminders.py Normal file
View File

@@ -0,0 +1,228 @@
"""
bal.core.reminders
==================
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
iCalendar (.ics) document.
Everything in this module is stdlib-only, so it can be imported and tested
without Electrum or Qt (e.g. in the lint venv).
"""
import os
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Optional
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
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]
def format_time(time) -> str:
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def fold_ical_line(line: str, limit: int = 75) -> str:
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
multi-byte UTF-8 characters. Continuation lines start with a space."""
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# cut without splitting a UTF-8 continuation byte
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)
def ical_escape(text: str) -> str:
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
def write_temp_ics(content: str) -> str:
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
def build_ics_reminders(
*,
locktime: datetime,
basic_mode: bool,
description: str,
summary: str,
wallet_name: str,
heirs_details: str,
version: str,
num_reminders: int = 3,
now: Optional[datetime] = None,
threshold: Optional[datetime] = None,
) -> Optional[str]:
"""Build the ``.ics`` content with one VEVENT per reminder date.
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
user sees several distinct appointments in their calendar. The reminder
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
period).
Args:
locktime: the delivery deadline (datetime).
basic_mode: use the fixed BASIC offsets instead of spreading over the
check-alive period.
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
``$heirs_complete`` placeholders are substituted and escaped.
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
wallet_name: label used in the UID and template substitutions.
heirs_details: pre-formatted heir list injected into ``description``.
version: plugin version, embedded in the PRODID line.
num_reminders: requested reminder count (ADVANCED mode only).
now: "today" reference; defaults to ``datetime.now()``.
threshold: check-alive date (ADVANCED mode only; required there).
Returns:
The ``.ics`` content string, or ``None`` when no reminder falls in the
future (the delivery date is too close or already passed) so the caller
can show a warning instead of producing an empty-looking file.
"""
now = now if now is not None else datetime.now()
if basic_mode:
days_to_deadline = (locktime - now).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
if threshold is None:
raise ValueError("threshold is required in ADVANCED mode")
days = (locktime - threshold).days
offsets = compute_reminder_offsets(days, num_reminders)
# ToDo #2: no future reminder means there are no events to write. Return
# None so the caller shows a clear warning instead of an empty .ics file.
if not offsets:
return None
event_description = ical_escape(
f"{description}"
.replace("$wallet_name", str(wallet_name))
.replace("$heirs_complete", heirs_details)
)
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(wallet_name)}-{offset}d",
f"DTSTAMP:{format_time(now)}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{event_summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"