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( 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
): ):
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: if len(self) <= 0:
_logger.info("while building transactions there was no heirs") _logger.info("while building transactions there was no heirs")
return return
@@ -877,16 +889,22 @@ class Heirs(dict, Logger):
return (address, amount, locktime) return (address, amount, locktime)
@staticmethod @staticmethod
def _validate(data, timestamp_to_check=False): def _validate(data, timestamp_to_check=False, persist=True):
for k, v in list(data.items()): for k, v in list(data.items()):
if k == "heirs": if k == "heirs":
return Heirs._validate(v, timestamp_to_check) return Heirs._validate(v, timestamp_to_check, persist)
try: try:
Heirs.validate_heir(k, v, timestamp_to_check) Heirs.validate_heir(k, v, timestamp_to_check)
except Exception as e: except Exception as e:
_logger.info(f"exception heir removed {e}") _logger.info(f"exception heir removed {e}")
if persist:
data.pop(k) 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 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" 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( self.WILLEXECUTORS = BalConfig(
config, config,
"bal_willexecutors", "bal_willexecutors",
@@ -325,7 +328,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000, "base_fee": 100000,
"status": "New", "status": "New",
"info": "Bitcoin After Life Will Executor", "info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True, "selected": True,
} }
}, },
@@ -334,7 +337,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000, "base_fee": 100000,
"status": "New", "status": "New",
"info": "Bitcoin After Life Will Executor", "info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"selected": True, "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"

View File

@@ -7,11 +7,16 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application. into an RFC-5545 .ics file and opens it with the OS default application.
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
the Qt button and the OS/subprocess glue.
""" """
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QToolButton from PyQt6.QtWidgets import QToolButton
from ...core.reminders import write_temp_ics
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
@@ -57,7 +62,7 @@ class BalCalendarButton(QToolButton):
try: try:
content = self._ics_provider() content = self._ics_provider()
if content: if content:
self._calendar_temp_path = BalCalendar.write_temp_ics(content) self._calendar_temp_path = write_temp_ics(content)
else: else:
self._calendar_temp_path = None self._calendar_temp_path = None
self._bal_window.show_warning( self._bal_window.show_warning(
@@ -149,13 +154,6 @@ class BalCalendarButton(QToolButton):
class BalCalendar: class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod @staticmethod
def open_with_default_app(calendar_app, path): def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app") _logger.debug("opening calendar app")
@@ -184,37 +182,3 @@ class BalCalendar:
if os.path.isdir(desktop): if os.path.isdir(desktop):
return desktop return desktop
return home return home
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def ical_escape(text: str) -> str:
# escape per RFC5545: backslash, ; , newlines
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(
BalCalendar.fold_ical_line(line)
for line in text.split("\r\n")
)
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# taglia senza spezzare byte UTF-8
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte di 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)

View File

@@ -12,7 +12,7 @@ hosts a few GUI helpers that do not deserve a module of their own:
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid. * :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
* :func:`log_error` - format an exception traceback for a dialog. * :func:`log_error` - format an exception traceback for a dialog.
* :func:`export_meta_gui` - export plugin metadata to a JSON file. * :func:`export_meta_gui` - export plugin metadata to a JSON file.
* :class:`CheckAliveError`- raised when the "check alive" date is in the past. (:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
""" """
import copy import copy
@@ -181,18 +181,6 @@ def add_widget(grid, label, widget, row, help_):
class CheckAliveError(Exception):
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 log_error(exec_info, window=None): def log_error(exec_info, window=None):
"""Log an error and optionally show it. """Log an error and optionally show it.

View File

@@ -19,14 +19,14 @@ use them (see ``lists`` imports below).
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .calendar import BalCalendar, BalCalendarButton from ...core.checkalive import CheckAliveError
from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import ( from .widgets import (
WillSettingsWidget, WillSettingsWidget,
WillWidget, WillWidget,
basic_reminder_offsets,
compute_reminder_offsets,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -1598,7 +1598,7 @@ class BalBuildWillDialog(BalDialog):
def _ics_provider(self): def _ics_provider(self):
"""Return the .ics content for the current will data.""" """Return the .ics content for the current will data."""
from datetime import datetime, timedelta from datetime import datetime
try: try:
locktime_ts = Util.parse_locktime_string( locktime_ts = Util.parse_locktime_string(
@@ -1606,22 +1606,25 @@ class BalBuildWillDialog(BalDialog):
) )
locktime = datetime.fromtimestamp(locktime_ts) locktime = datetime.fromtimestamp(locktime_ts)
if self.bal_window.bal_plugin.is_basic_mode(): basic_mode = self.bal_window.bal_plugin.is_basic_mode()
days_to_deadline = (locktime - datetime.now()).days if basic_mode:
offsets = basic_reminder_offsets(days_to_deadline) raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else: else:
threshold_ts = BalTimestamp( threshold_ts = BalTimestamp(
self.bal_window.will_settings["threshold"] self.bal_window.will_settings["threshold"]
).to_timestamp() ).to_timestamp()
threshold = datetime.fromtimestamp(threshold_ts) threshold = datetime.fromtimestamp(threshold_ts)
days = (locktime - threshold).days raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
try: try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception: except Exception:
count = 3 num_reminders = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
heirs_details = "\r\n".join( heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, " f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1629,56 +1632,21 @@ class BalBuildWillDialog(BalDialog):
for heir in self.bal_window.heirs for heir in self.bal_window.heirs
) )
if self.bal_window.bal_plugin.is_basic_mode(): # ToDo #2: when no reminder falls in the future (the delivery date is
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default # too close or already passed), build_ics_reminders returns None so
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default # the caller shows a clear warning instead of producing an empty,
else: # seemingly-broken .ics file.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() return build_ics_reminders(
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() locktime=locktime,
basic_mode=basic_mode,
event_description = BalCalendar.ical_escape( description=raw_description,
f"{raw_description}" summary=raw_summary,
.replace("$wallet_name", str(self.bal_window.wallet)) wallet_name=str(self.bal_window.wallet),
.replace("$heirs_complete", heirs_details) heirs_details=heirs_details,
version=self.bal_window.bal_plugin.version,
num_reminders=num_reminders,
threshold=threshold,
) )
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{self.bal_window.bal_plugin.version}",
]
total = len(offsets)
# ToDo #2: if no reminder falls in the future (the delivery date is
# too close or already passed), there are no events to write.
# Return None so the caller shows a clear warning instead of
# producing an empty, seemingly-broken .ics file.
if total == 0:
return None
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{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"
except Exception as e: except Exception as e:
_logger.error(f"failed to generate .ics: {e}") _logger.error(f"failed to generate .ics: {e}")
return None return None
@@ -1706,7 +1674,12 @@ class BalBuildWillDialog(BalDialog):
try: try:
if txs := self.bal_window.sign_transactions(password): if txs := self.bal_window.sign_transactions(password):
for txid, tx in txs.items(): for txid, tx in txs.items():
self.bal_window.willitems[txid].tx = copy.deepcopy(tx) # Re-parse instead of deepcopy (the signed tx can carry
# wallet-derived input info holding a threading.RLock,
# which copy.deepcopy cannot pickle).
self.bal_window.willitems[txid].tx = Will.get_tx_from_any(
str(tx)
)
self.bal_window.save_willitems() self.bal_window.save_willitems()
self.msg_set_signing(self.msg_ok()) self.msg_set_signing(self.msg_ok())
except Exception as e: except Exception as e:

View File

@@ -20,6 +20,13 @@ Contents:
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...core.input_rules import (
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
)
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton from .calendar import BalCalendar, BalCalendarButton
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
@@ -28,86 +35,6 @@ if TYPE_CHECKING:
from .window import BalWindow from .window import BalWindow
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]
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
doubleClicked = pyqtSignal() doubleClicked = pyqtSignal()
@@ -219,39 +146,12 @@ class BalTxFeesWidget(QWidget):
class _LockTimeEditor: class _LockTimeEditor(LockTimeEditor):
min_allowed_value = NLOCKTIME_MIN """Qt-side locktime editor base.
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:
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:
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
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
this is just the mixin that lets Qt widgets reuse them.
"""
class BalTimeEditWidget(QWidget, _LockTimeEditor): class BalTimeEditWidget(QWidget, _LockTimeEditor):
@@ -572,51 +472,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.isyears = False self.isyears = False
self.time_edit = time_edit self.time_edit = time_edit
@staticmethod
def replace_str(text):
"""Strip the relative-time suffixes (d/y) from the 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(self, s, pos, appendix):
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = self.replace_str(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def numbify(self): def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted. # Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always # The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed. # UNIX timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition() pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars])) s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
s = "".join([i for i in text if i in chars]) self.isdays = isdays
self.isdays = False self.isyears = isyears
self.isyears = False
pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y")
if "d" in s:
self.isdays = True
if "y" in s:
self.isyears = True
if self.isdays:
s = self.replace_str(s) + "d"
if self.isyears:
s = self.replace_str(s) + "y"
self.blockSignals(True) self.blockSignals(True)
self.setText(s) self.setText(s)
self.blockSignals(False) self.blockSignals(False)
@@ -1073,11 +938,12 @@ class WillSettingsWidget(QWidget):
sees several distinct appointments in their calendar. sees several distinct appointments in their calendar.
The number of events is read from the NUM_REMINDERS setting (default 3, The number of events is read from the NUM_REMINDERS setting (default 3,
capped at 5 by the settings dialog). Their dates are computed with capped at 5 by the settings dialog). Their dates are computed by
``compute_reminder_offsets``: the offsets are spread uniformly across the ``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
check-alive period and the LAST event always falls one day before the across the check-alive period; the LAST event always falls one day
delivery deadline (locktime). If the period is shorter than the before the delivery deadline / locktime). If the period is shorter than
requested number of reminders, at most one event per day is produced. the requested number of reminders, at most one event per day is
produced.
Each event: Each event:
* is placed on ``locktime - offset`` days (its own visible date); * is placed on ``locktime - offset`` days (its own visible date);
@@ -1092,103 +958,23 @@ class WillSettingsWidget(QWidget):
path the user picks in the save dialog (default name "BAL_will_event.ics" path the user picks in the save dialog (default name "BAL_will_event.ics"
on the Desktop). on the Desktop).
""" """
now = BalCalendar.format_time(datetime.now()) # The .ics content (offsets, escaping, folding, VEVENT layout) is built
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
# locktime = delivery deadline. It is exposed by the date widget as # reminder falls in the future it returns None (ToDo #2) and the user is
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum # warned instead of getting an empty-looking file.
# transaction locktime, so the calendar uses the correct delivery date. ics_content = self._ics_provider()
locktime = self.widgets["locktime"].alarm if not ics_content:
self.bal_window.show_warning(
# BASIC vs ADVANCED reminder strategy. _(
# "No reminders were saved: the delivery date is too "
# In ADVANCED mode the reminders are spread uniformly across the "close (or already passed)"
# 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(
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
for heir in self.bal_window.heirs
) )
# BASIC mode: use factory defaults (the hidden settings are ignored).
# ADVANCED mode: use the user-configured values.
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
) )
return
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{self.bal_window.bal_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 = BalCalendar.format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
summary = BalCalendar.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(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the # Keep the generated .ics in a temp file; it is copied to the path the
# user picks below. # user picks below.
self.temp_path = BalCalendar.write_temp_ics(ics_content) self.temp_path = write_temp_ics(ics_content)
# Group D / D1b: always ask the user WHERE to save the .ics file (the # Group D / D1b: always ask the user WHERE to save the .ics file (the
# plugin no longer tries to open it with a calendar app). The save # plugin no longer tries to open it with a calendar app). The save
@@ -1241,26 +1027,31 @@ class WillSettingsWidget(QWidget):
def _ics_provider(self): def _ics_provider(self):
"""Return the .ics content for the current locktime/threshold values. """Return the .ics content for the current locktime/threshold values.
Used by :class:`BalCalendarButton` as its content provider. Used by :class:`BalCalendarButton` as its content provider. The whole
document (reminder offsets, escaping, folding, VEVENT layout) is built
by the pure :func:`bal.core.reminders.build_ics_reminders`.
""" """
from datetime import datetime, timedelta
try: try:
locktime = self.widgets["locktime"].alarm locktime = self.widgets["locktime"].alarm
if self.bal_window.bal_plugin.is_basic_mode(): basic_mode = self.bal_window.bal_plugin.is_basic_mode()
days_to_deadline = (locktime - datetime.now()).days if basic_mode:
offsets = basic_reminder_offsets(days_to_deadline) # BASIC mode: use factory defaults (the hidden settings are
# ignored) and the fixed 30/10/1 offsets.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else: else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
threshold = self.widgets["threshold"].alarm threshold = self.widgets["threshold"].alarm
days = (locktime - threshold).days
try: try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception: except Exception:
count = 3 num_reminders = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
heirs_details = "\r\n".join( heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, " f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1268,50 +1059,17 @@ class WillSettingsWidget(QWidget):
for heir in self.bal_window.heirs for heir in self.bal_window.heirs
) )
if self.bal_window.bal_plugin.is_basic_mode(): return build_ics_reminders(
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default locktime=locktime,
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default basic_mode=basic_mode,
else: description=raw_description,
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() summary=raw_summary,
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() wallet_name=str(self.bal_window.wallet),
heirs_details=heirs_details,
event_description = BalCalendar.ical_escape( version=self.bal_window.bal_plugin.version,
f"{raw_description}" num_reminders=num_reminders,
.replace("$wallet_name", str(self.bal_window.wallet)) threshold=threshold,
.replace("$heirs_complete", heirs_details)
) )
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{self.bal_window.bal_plugin.version}",
]
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{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"
except Exception as e: except Exception as e:
_logger.error(f"failed to generate .ics: {e}") _logger.error(f"failed to generate .ics: {e}")
return None return None
@@ -1354,40 +1112,20 @@ class PercAmountEdit(BTCAmountEdit):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount) super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self): def numbify(self):
# The text sanitisation lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
if text == "!": if text == "!":
self.shortcut.emit() self.shortcut.emit()
return return
pos = self.cursorPosition() pos = self.cursorPosition()
chars = "0123456789%" s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
chars += DECIMAL_POINT
s = "".join([i for i in text if i in chars])
if "%" in s:
self.is_perc = True
s = s.replace("%", "")
else:
self.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 self.is_perc:
s += "%"
self.setText(s) self.setText(s)
self.setModified(self.hasFocus()) self.setModified(self.hasFocus())
self.setCursorPosition(pos) self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]: def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try: return parse_perc_amount(text, DECIMAL_POINT)
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
def _get_text_from_amount(self, amount): def _get_text_from_amount(self, amount):
out = super()._get_text_from_amount(amount) out = super()._get_text_from_amount(amount)

View File

@@ -14,8 +14,16 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
it with the GUI. it with the GUI.
""" """
import json
import threading import threading
from electrum.util import MyEncoder
from ...core.checkalive import (
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
from .dialogs import ( from .dialogs import (
@@ -123,7 +131,22 @@ class BalWindow:
for k in keys: for k in keys:
del self.will[k] del self.will[k]
for wid, w in self.willitems.items(): for wid, w in self.willitems.items():
self.will[wid] = w.to_dict() d = w.to_dict()
# Store the tx in its serialized form: the wallet DB deep-copies
# every value on save (JsonDB.put), and a live Transaction carrying
# wallet-derived input info (utxo / script_descriptor, which hold a
# threading.RLock) cannot be deep-copied. Serializing to a string
# matches how the will is re-read (get_will -> tx_from_any).
d["tx"] = str(d["tx"])
# Mirror the encoder the wallet DB uses: JsonDB.put returns False
# (silently dropping the will) when a value cannot be serialized,
# so prove it here and fail loudly instead.
try:
json.dumps(d, cls=MyEncoder)
except Exception as e:
_logger.error(f"save_willitems: will {wid} is not serializable: {e!r}")
raise
self.will[wid] = d
def init_will(self): def init_will(self):
_logger.info("********************init_____will____________**********") _logger.info("********************init_____will____________**********")
@@ -614,11 +637,11 @@ class BalWindow:
# against the current moment, i.e. the Check Alive effectively does # against the current moment, i.e. the Check Alive effectively does
# not exist, while the delivery time (locktime) is still fully # not exist, while the delivery time (locktime) is still fully
# enforced. ADVANCED mode keeps the user-controlled Check Alive # enforced. ADVANCED mode keeps the user-controlled Check Alive
# exactly as before. # exactly as before. The policy itself lives in
if self.bal_plugin.is_basic_mode(): # ``bal.core.checkalive.resolve_date_to_check``.
self.date_to_check = datetime.now().timestamp() self.date_to_check = resolve_date_to_check(
else: self.bal_plugin.is_basic_mode(), self.will_settings
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() )
# found = False # found = False
# NOTE: block-height tracking removed (A1) - locktimes are always # NOTE: block-height tracking removed (A1) - locktimes are always
# UNIX timestamps now, so we no longer read the current block height # UNIX timestamps now, so we no longer read the current block height
@@ -635,9 +658,10 @@ class BalWindow:
# flow. In BASIC we therefore SKIP this check entirely, so a passed # flow. In BASIC we therefore SKIP this check entirely, so a passed
# check-alive date never forces a postpone/rewrite of the will. The # check-alive date never forces a postpone/rewrite of the will. The
# delivery time (locktime) is unaffected and still fully enforced. # delivery time (locktime) is unaffected and still fully enforced.
if ( # The BASIC/ADVANCED rule lives in
not self.bal_plugin.is_basic_mode() # ``bal.core.checkalive.check_alive_expired``.
and self.date_to_check < datetime.now().timestamp() if check_alive_expired(
self.bal_plugin.is_basic_mode(), self.date_to_check
): ):
raise CheckAliveError(self.date_to_check) raise CheckAliveError(self.date_to_check)
@@ -945,7 +969,12 @@ class BalWindow:
targets = Will.only_valid(willitems) targets = Will.only_valid(willitems)
for txid in targets: for txid in targets:
wi = willitems[txid] wi = willitems[txid]
tx = copy.deepcopy(wi.tx) # Do NOT deepcopy: the stored tx carries wallet-derived objects
# (utxo / script_descriptor) that hold a threading.RLock, and
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
# from the serialized form instead, which is exactly how the will
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
tx = Will.get_tx_from_any(str(wi.tx))
if wi.get_status("COMPLETE"): if wi.get_status("COMPLETE"):
txs[txid] = tx txs[txid] = tx
continue continue
@@ -1057,7 +1086,10 @@ class BalWindow:
def on_success(txs): def on_success(txs):
if txs: if txs:
for txid, tx in txs.items(): for txid, tx in txs.items():
willitems[txid].tx = copy.deepcopy(tx) # Re-parse instead of deepcopy: the signed tx may carry
# wallet-derived input info holding a threading.RLock, which
# copy.deepcopy cannot pickle (see sign_transactions above).
willitems[txid].tx = Will.get_tx_from_any(str(tx))
if not external: if not external:
self.will[txid] = willitems[txid].to_dict() self.will[txid] = willitems[txid].to_dict()
try: try:
@@ -1251,7 +1283,9 @@ class BalWindow:
# very first action in a session). Fall back to "now" so the local # very first action in a session). Fall back to "now" so the local
# validity check and the trailing update_all() always have it. # validity check and the trailing update_all() always have it.
if not hasattr(self, "date_to_check") or self.date_to_check is None: if not hasattr(self, "date_to_check") or self.date_to_check is None:
self.date_to_check = datetime.now().timestamp() self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
)
for wid, wi in imported.items(): for wid, wi in imported.items():
if wid in self.willitems: if wid in self.willitems:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_checkalive.py
"""
import sys
import time
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
# ------------------------------------------------------------------ #
# CheckAliveError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ #
# resolve_date_to_check
# ------------------------------------------------------------------ #
def test_basic_mode_uses_now():
fake_now = 1_800_000_000.0
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
def test_advanced_mode_uses_threshold_absolute():
threshold = time.time() + 5 * 86400
settings = {"threshold": threshold}
result = resolve_date_to_check(False, settings)
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# "30d" resolves to a future timestamp (midnight-normalised).
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
# ------------------------------------------------------------------ #
# check_alive_expired
# ------------------------------------------------------------------ #
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core checkalive tests passed")

View File

@@ -0,0 +1,165 @@
"""
Tests for ``bal.core.input_rules`` (pure, GUI-free).
Covers the locktime acceptance bounds, the RAW locktime sanitisation, and the
percentage-or-amount field normalisation that the Qt editors wrap.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_input_rules.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.input_rules import ( # noqa: E402 (path insert above)
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
replace_dy_suffixes,
)
# ------------------------------------------------------------------ #
# LockTimeEditor
# ------------------------------------------------------------------ #
def test_locktime_editor_is_acceptable():
assert LockTimeEditor.is_acceptable_locktime(100) is True
assert LockTimeEditor.is_acceptable_locktime(0) is True
assert LockTimeEditor.is_acceptable_locktime(-1) is False
assert LockTimeEditor.is_acceptable_locktime(None) is True
def test_locktime_editor_is_acceptable_string():
assert LockTimeEditor.is_acceptable_locktime("100") is True
assert LockTimeEditor.is_acceptable_locktime("abc") is False
assert LockTimeEditor.is_acceptable_locktime("") is True
def test_locktime_editor_min_max():
assert LockTimeEditor.min_allowed_value >= 0
assert LockTimeEditor.max_allowed_value > LockTimeEditor.min_allowed_value
def test_locktime_editor_get_max_allowed_timestamp():
# Always a valid, fromtimestamp-able value (the Windows 2038 clamp).
ts = LockTimeEditor.get_max_allowed_timestamp()
import datetime
datetime.datetime.fromtimestamp(ts) # must not raise
assert ts <= 2**32 - 1
def test_locktime_editor_subclass_bounds():
class Tight(LockTimeEditor):
min_allowed_value = 1000
max_allowed_value = 2000
assert Tight.is_acceptable_locktime(1500) is True
assert Tight.is_acceptable_locktime(999) is False
assert Tight.is_acceptable_locktime(2001) is False
# ------------------------------------------------------------------ #
# replace_dy_suffixes / RAW locktime sanitisation
# ------------------------------------------------------------------ #
def test_replace_dy_suffixes():
# replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now).
assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix)
assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains
assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_normalize_locktime_raw_empty():
s, isdays, isyears, pos = normalize_locktime_raw_text("", 0)
assert s == ""
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_days():
s, isdays, isyears, pos = normalize_locktime_raw_text("30d", 3)
assert s == "30d"
assert isdays is True
assert isyears is False
def test_normalize_locktime_raw_years():
s, isdays, isyears, pos = normalize_locktime_raw_text("2y", 2)
assert s == "2y"
assert isdays is False
assert isyears is True
def test_normalize_locktime_raw_strips_bad_chars():
s, isdays, isyears, pos = normalize_locktime_raw_text("12a34b56", 8)
assert s == "123456"
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_single_suffix():
# "1y30d" collapses to a single suffix; "d" wins (processed first), so the
# "y" is dropped along with all letters.
s, isdays, isyears, pos = normalize_locktime_raw_text("1y30d", 5)
assert isdays is True
assert isyears is False
assert s == "130d"
# ------------------------------------------------------------------ #
# Percentage-or-amount normalisation
# ------------------------------------------------------------------ #
def test_perc_normalize_percent():
s, is_perc = normalize_perc_amount_text("50%", ".")
assert is_perc is True
assert s == "50%"
def test_perc_normalize_no_percent():
s, is_perc = normalize_perc_amount_text("123", ".")
assert is_perc is False
assert s == "123"
def test_perc_normalize_strips_invalid():
s, is_perc = normalize_perc_amount_text("1a2b3", ".")
assert s == "123"
def test_perc_normalize_decimal_limit():
# At most 8 decimals after the point.
s, is_perc = normalize_perc_amount_text("1.1234567890123", ".")
assert s == "1.12345678"
def test_perc_parse():
from decimal import Decimal
assert parse_perc_amount("50%", ".") == Decimal(50)
assert parse_perc_amount("123.45", ".") == Decimal("123.45")
assert parse_perc_amount("abc", ".") is None
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core input_rules tests passed")

View File

@@ -0,0 +1,299 @@
"""
Tests for ``bal.core.reminders`` (pure, GUI-free).
Covers the reminder-offset rules (BASIC + ADVANCED), the RFC-5545 helpers
(format_time, ical_escape, fold_ical_line), write_temp_ics, and the unified
``build_ics_reminders`` builder.
This module imports only ``bal.core``, so it runs in the lint venv (no
Electrum/Qt needed) as well as the runtime venv:
Run:
python3 tests/test_core_reminders.py
"""
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.reminders import ( # noqa: E402 (path insert above)
BASIC_REMINDER_OFFSETS,
basic_reminder_offsets,
build_ics_reminders,
compute_reminder_offsets,
fold_ical_line,
format_time,
ical_escape,
write_temp_ics,
)
# ------------------------------------------------------------------ #
# compute_reminder_offsets - distribution rules
# ------------------------------------------------------------------ #
def test_offsets_long_period_three_reminders():
"""30-day period, 3 reminders: spread out, all before the deadline."""
offsets = compute_reminder_offsets(30, 3)
assert len(offsets) == 3
# All strictly before the deadline (offset >= 1 means "n days before end").
assert all(o >= 1 for o in offsets)
# Sorted earliest-first (largest offset first).
assert offsets == sorted(offsets, reverse=True)
# One reminder near the start, one near the end.
assert max(offsets) == 30
assert min(offsets) == 1
def test_offsets_capped_at_one_per_day():
"""Short period: at most one reminder per available day."""
# 2 days but 3 requested -> only 2 reminders, one per day.
assert compute_reminder_offsets(2, 3) == [2, 1]
# 1 day but 3 requested -> a single reminder, the day before the deadline.
assert compute_reminder_offsets(1, 3) == [1]
def test_offsets_empty_when_no_room():
"""No reminders when there is no day before the deadline."""
assert compute_reminder_offsets(0, 3) == []
assert compute_reminder_offsets(-5, 3) == []
# A non-positive count also yields nothing.
assert compute_reminder_offsets(30, 0) == []
def test_offsets_never_exceed_requested_count():
"""The number of reminders never exceeds the requested count (max 5)."""
offsets = compute_reminder_offsets(100, 5)
assert len(offsets) == 5
assert all(o >= 1 for o in offsets)
# Distinct offsets only (no duplicate alarms on the same day).
assert len(set(offsets)) == len(offsets)
def test_offsets_single_reminder_is_day_before_deadline():
"""A single requested reminder fires one day before the deadline."""
assert compute_reminder_offsets(30, 1) == [1]
# ------------------------------------------------------------------ #
# basic_reminder_offsets - fixed BASIC-mode offsets
# ------------------------------------------------------------------ #
def test_basic_offsets_full_period():
"""A far-away delivery keeps all three fixed offsets (30, 10, 1)."""
assert basic_reminder_offsets(365) == [30, 10, 1]
def test_basic_offsets_truncated_by_horizon():
"""Offsets beyond the delivery horizon are dropped."""
assert basic_reminder_offsets(20) == [10, 1]
assert basic_reminder_offsets(5) == [1]
def test_basic_offsets_empty():
"""No future offsets when the delivery is less than a day away."""
assert basic_reminder_offsets(0) == []
assert basic_reminder_offsets(-10) == []
def test_basic_offsets_always_from_fixed_set():
"""Every returned offset is one of the fixed BASIC offsets."""
for horizon in range(0, 40):
result = basic_reminder_offsets(horizon)
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=tz)
assert format_time(dt) == "20250601T103045Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
assert ical_escape("hello world") == "hello world"
def test_ical_escape_backslash():
assert ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\nline2"
result = ical_escape(text)
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
assert ical_escape("\\;,") == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
assert fold_ical_line("SUMMARY:Test") == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "SUMMARY:" + "a" * 100
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
def test_fold_ical_line_unicode():
line = "SUMMARY:" + "é" * 50
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ #
# build_ics_reminders - unified builder
# ------------------------------------------------------------------ #
_LOCKTIME = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
def _common_kwargs():
return dict(
locktime=_LOCKTIME,
description="BAL will of $wallet_name\r\n$heirs_complete",
summary="BAL - Will execution of $wallet_name",
wallet_name="karen7",
heirs_details=" alice - bc1qalice, bob - bc1qbob",
version="0.6.1",
)
def test_build_ics_advanced_separate_events():
"""ADVANCED: 30-day period -> offsets [30, 16, 1], one VEVENT each."""
content = build_ics_reminders(
basic_mode=False,
num_reminders=3,
now=datetime(2026, 7, 1, 0, 0, 0, tzinfo=timezone.utc),
threshold=datetime(2026, 6, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
assert content.startswith("BEGIN:VCALENDAR")
assert content.endswith("\r\n")
lines = content.split("\r\n")
assert lines.count("BEGIN:VEVENT") == 3
assert lines.count("END:VEVENT") == 3
assert "BEGIN:VALARM" not in lines
# Unique UIDs, numbered summaries, last event one day before the deadline.
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == len(set(uids)) == 3
assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines)
last_dt = format_time(_LOCKTIME - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines
# Template substitution; the CRLF inside the description stays as a line
# break in the folded DESCRIPTION (no literal "\n" escaping in ical_escape).
desc = [ln for ln in lines if ln.startswith("DESCRIPTION:")]
assert desc and "karen7" in desc[0]
assert any(ln.lstrip().startswith("alice") for ln in lines)
def test_build_ics_basic_uses_fixed_offsets():
"""BASIC: fixed offsets (30, 10, 1) filtered to the future, threshold not
required."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
lines = content.split("\r\n")
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == 3
# The first event is the 30-day offset.
assert "bal-karen7-30d" in uids[0]
def test_build_ics_returns_none_when_no_reminders():
"""No future reminder -> None (the aligned behavior), not an empty file."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 7, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is None
def test_build_ics_advanced_requires_threshold():
"""ADVANCED mode without a threshold is a programming error."""
try:
build_ics_reminders(basic_mode=False, **_common_kwargs())
except ValueError:
return
raise AssertionError("expected ValueError for missing threshold in ADVANCED")
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core reminders tests passed")

View File

@@ -10,13 +10,12 @@ Covered behaviour:
most one reminder per available day, and never returns more reminders than most one reminder per available day, and never returns more reminders than
requested. requested.
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports ``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like so these tests run without Qt (or Electrum) at all.
the other GUI tests.
Run: Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ source /home/steal/devel/bal/electrum/env/bin/activate
python3 -m pytest tests/test_group_d_alarms.py -q python3 tests/test_group_d_alarms.py
""" """
import os import os
@@ -25,7 +24,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
from bal.gui.qt.widgets import compute_reminder_offsets from bal.core.reminders import compute_reminder_offsets
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks

View File

@@ -37,10 +37,14 @@ import pytest # pyright: ignore[reportMissingImports]
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.reminders import (
compute_reminder_offsets,
format_time,
ical_escape,
write_temp_ics,
)
from bal.core.will import HeirNotFoundException, Will, WillItem from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
from bal.gui.qt.calendar import BalCalendar
from bal.gui.qt.widgets import compute_reminder_offsets
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused from test_core_will.py so WillItem can parse a real tx. # version 2). Reused from test_core_will.py so WillItem can parse a real tx.
@@ -196,8 +200,8 @@ def test_e1_build_separate_events_for_karen7():
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"] lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
for idx, offset in enumerate(offsets, start=1): for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset)) event_dt = format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})") summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([ lines.extend([
"BEGIN:VEVENT", "BEGIN:VEVENT",
f"UID:bal-{wallet}-{offset}d", f"UID:bal-{wallet}-{offset}d",
@@ -219,7 +223,7 @@ def test_e1_build_separate_events_for_karen7():
assert any("(reminder 1/3)" in ln for ln in lines) assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines) assert any("(reminder 3/3)" in ln for ln in lines)
# The LAST event (offset 1) sits one day before the locktime. # The LAST event (offset 1) sits one day before the locktime.
last_dt = BalCalendar.format_time(locktime - timedelta(days=1)) last_dt = format_time(locktime - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines assert f"DTSTART:{last_dt}" in lines
@@ -227,7 +231,7 @@ def test_e1_event_description_escaping():
"""Special iCalendar characters in karen7's event text are escaped so """Special iCalendar characters in karen7's event text are escaped so
the .ics file stays valid.""" the .ics file stays valid."""
raw = "Wallet karen7; heirs: alice, bob" raw = "Wallet karen7; heirs: alice, bob"
escaped = BalCalendar.ical_escape(raw) escaped = ical_escape(raw)
assert "\\;" in escaped # semicolon escaped assert "\\;" in escaped # semicolon escaped
assert "\\," in escaped # comma escaped assert "\\," in escaped # comma escaped
assert "karen7" in escaped assert "karen7" in escaped
@@ -245,7 +249,7 @@ def test_e1_write_temp_ics_for_karen7():
"END:VEVENT\r\n" "END:VEVENT\r\n"
"END:VCALENDAR\r\n" "END:VCALENDAR\r\n"
) )
path = BalCalendar.write_temp_ics(content) path = write_temp_ics(content)
try: try:
assert os.path.isfile(path) assert os.path.isfile(path)
with open(path, "rb") as f: with open(path, "rb") as f:

View File

@@ -10,13 +10,12 @@ that would fall in the past.
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
drives that decision. drives that decision.
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports ``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like so these tests run without Qt (or Electrum) at all.
the other GUI tests.
Run: Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ source /home/steal/devel/bal/electrum/env/bin/activate
python3 -m pytest tests/test_group_g_basic_calendar.py -q python3 tests/test_group_g_basic_calendar.py
""" """
import os import os
@@ -24,7 +23,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) 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 from bal.core.reminders import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
def test_basic_offsets_all_future(): def test_basic_offsets_all_future():

View File

@@ -3,116 +3,99 @@ Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
Background (reported by the owner): with the plugin left in BASIC mode (the Background (reported by the owner): with the plugin left in BASIC mode (the
default), the "Check Alive" (threshold) field is hidden from the user and default), the "Check Alive" (threshold) field is hidden from the user and
stays stuck at its old/default value (roughly "today + 11 months", i.e. one stays stuck at its old/default value. If the user then anticipates the
year minus the default 30-day margin). If the user then anticipates the delivery time (locktime) to something earlier than that stale threshold, the
delivery time (locktime) to something earlier than that stale threshold - e.g. checks that compare locktime against ``date_to_check`` would incorrectly treat
"in 1 month" - two different checks that compare locktime against the will as "expired"/"invalid", even though the whole Check Alive concept is
``date_to_check`` (the resolved threshold) would incorrectly treat the will as supposed to be inert in BASIC mode.
"expired"/"invalid", even though the whole Check Alive concept is supposed to
be inert in BASIC mode.
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track The real fix lives in ``BalWindow.init_class_variables`` (window.py), which
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it, sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
so it is always < locktime by construction. In ADVANCED mode nothing changes: pure policy in ``bal.core.checkalive``:
the stored threshold is used as-is.
These tests call the real production method directly (no GUI/Electrum wallet * ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
needed - it is a plain ``@staticmethod``), so they exercise the exact code reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
used at runtime rather than a re-implementation. * ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
Check-Alive guard should fire (never in BASIC).
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
needed, and no Qt import).
Run: Run:
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_group_i_basic_checkalive.py
""" """
import os
import sys import sys
import time import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above) from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS check_alive_expired,
resolve_date_to_check,
)
def test_offset_is_two_hours(): def test_basic_mode_resolves_to_now():
"""Owner-approved margin: exactly 2 hours.""" """BASIC mode: date_to_check is "now", never the stale stored threshold."""
assert OFFSET == 2 * 60 * 60 fake_now = 1_800_000_000.0
result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
assert result == fake_now
def _relative_days_to_midnight_timestamp(days): def test_basic_mode_ignores_stored_threshold():
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N """BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
days", truncated to midnight. Used to compute the expected value in does NOT jump to the old ~11-month threshold - this is the exact bug
tests without duplicating the parsing logic itself.""" scenario reported by the owner."""
from datetime import datetime, timedelta stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
fake_now = 1_800_000_000.0
now = datetime.now() result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
return ( assert result == fake_now
(now + timedelta(days=days))
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
)
def test_basic_mode_relative_locktime_one_year(): def test_advanced_mode_uses_stored_threshold():
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier.""" """ADVANCED mode: behaviour stays exactly as before - the stored threshold
result = BalWindow.compute_date_to_check(True, "1y", "30d") is used as-is, regardless of the locktime value."""
expected_locktime = _relative_days_to_midnight_timestamp(365)
assert abs(result - (expected_locktime - OFFSET)) < 5
def test_basic_mode_anticipated_delivery_stays_consistent():
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
NOT the old ~11-month threshold - this is the exact bug scenario
reported by the owner."""
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
result = BalWindow.compute_date_to_check(
True, anticipated_locktime, stale_threshold
)
locktime_ts = _relative_days_to_midnight_timestamp(30)
# date_to_check must be (delivery - 2h), always strictly before delivery.
assert result < locktime_ts
assert abs((locktime_ts - OFFSET) - result) < 5
def test_basic_mode_date_to_check_always_before_locktime():
"""Regression guard for the reported bug: whatever the delivery date is
(even very close to "now"), date_to_check must stay before it."""
for relative_locktime in ("1d", "7d", "30d", "90d", "365d"):
result = BalWindow.compute_date_to_check(True, relative_locktime, "30d")
parsed_locktime = _relative_days_to_midnight_timestamp(
int(relative_locktime[:-1])
)
assert result < parsed_locktime, (
f"date_to_check ({result}) should be before locktime "
f"({parsed_locktime}) for locktime={relative_locktime}"
)
def test_advanced_mode_uses_stored_threshold_unchanged():
"""ADVANCED mode: behaviour must stay exactly as before this fix - the
stored threshold is used as-is, regardless of the locktime value."""
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
result = BalWindow.compute_date_to_check(False, "30d", absolute_threshold) result = resolve_date_to_check(False, {"threshold": absolute_threshold})
assert abs(result - absolute_threshold) < 1 assert abs(result - absolute_threshold) < 1
def test_basic_mode_falls_back_on_unparsable_locktime(): def test_advanced_mode_parses_relative_threshold():
"""If the locktime can't be parsed for any reason, BASIC mode must not """ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
crash: it falls back to the stored threshold, same as ADVANCED.""" result = resolve_date_to_check(False, {"threshold": "30d"})
absolute_threshold = time.time() + 5 * 86400 assert result > time.time()
result = BalWindow.compute_date_to_check(
True, {"not": "a valid locktime"}, absolute_threshold
) def test_basic_mode_never_expired():
assert abs(result - absolute_threshold) < 1 """BASIC mode can never be "expired": a passed check-alive date must never
force a postpone/rewrite of the will."""
assert check_alive_expired(True, time.time() - 10_000) is False
assert check_alive_expired(True, 1_000_000_000.0) is False
def test_advanced_mode_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_mode_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
def test_basic_mode_never_raises_check_alive_error():
"""Regression guard for the reported bug: whatever the delivery date,
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
assert not check_alive_expired(True, date_to_check)
# If it did fire, this is the exact exception that would be raised.
assert issubclass(CheckAliveError, Exception)
if __name__ == "__main__": if __name__ == "__main__":
test_offset_is_two_hours() for name in sorted(dir()):
test_basic_mode_relative_locktime_one_year() if name.startswith("test_"):
test_basic_mode_anticipated_delivery_stays_consistent() globals()[name]()
test_basic_mode_date_to_check_always_before_locktime() print(f" [OK] {name}")
test_advanced_mode_uses_stored_threshold_unchanged() print("[OK] All test_group_i_basic_checkalive tests passed.")
test_basic_mode_falls_back_on_unparsable_locktime()
print("All test_group_i_basic_checkalive tests passed.")

View File

@@ -1,126 +1,25 @@
""" """
Tests for ``bal.gui.qt.calendar``. Tests for ``bal.gui.qt.calendar``.
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line, Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
write_temp_ics, open_with_default_app. The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
""" """
import os
import sys import sys
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.calendar import BalCalendar from bal.gui.qt.calendar import BalCalendar
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert BalCalendar.format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
from datetime import timedelta
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
result = BalCalendar.format_time(dt)
assert result.endswith("Z")
assert result == "20250115T060000Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
text = "hello world"
assert BalCalendar.ical_escape(text) == "hello world"
def test_ical_escape_backslash():
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert BalCalendar.ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert BalCalendar.ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\r\nline2"
result = BalCalendar.ical_escape(text)
assert "\r\n" in result
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
text = "\\;,"
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
line = "SUMMARY:Test"
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "X-LONG:" + "a" * 100
result = BalCalendar.fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert len(parts) > 1
assert result.startswith("X-LONG:")
def test_fold_ical_line_unicode():
line = "DESCRIPTION:" + "\u20ac" * 40
result = BalCalendar.fold_ical_line(line, limit=75)
assert "\r\n " in result
assert "\u20ac" in result
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = BalCalendar.write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = BalCalendar.write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# open_with_default_app # open_with_default_app
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_open_with_default_app_not_found(): def test_open_with_default_app_not_found():
result = BalCalendar.open_with_default_app( result = BalCalendar.open_with_default_app(
"/nonexistent/calendar_app", "/tmp/fake.ics" "/nonexistent/calendar_app", "/tmp/fake.ics"

View File

@@ -1,7 +1,8 @@
""" """
Tests for ``bal.gui.qt.common``. Tests for ``bal.gui.qt.common``.
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui. Covers shown_cv, add_widget, log_error, export_meta_gui (and the
CheckAliveError exception, which now lives in ``bal.core.checkalive``).
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
@@ -44,23 +45,30 @@ def test_shown_cv_roundtrip():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# CheckAliveError # CheckAliveError (moved to bal.core.checkalive; kept tested via the
# common->core import chain)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_check_alive_error_default(): def test_check_alive_error_default():
err = common.CheckAliveError(1000000) from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000 assert err.timestamp_to_check == 1000000
def test_check_alive_error_str(): def test_check_alive_error_str():
err = common.CheckAliveError(1000000) from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
s = str(err) s = str(err)
assert "Check alive expired" in s assert "Check alive expired" in s
assert "1970" in s assert "1970" in s
def test_check_alive_error_subclass(): def test_check_alive_error_subclass():
assert issubclass(common.CheckAliveError, Exception) from bal.core.checkalive import CheckAliveError
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -118,23 +118,25 @@ def test_locktime_editor_min_max():
def test_locktime_raw_edit_replace_str(): def test_locktime_raw_edit_replace_str():
# replace_str only strips the day ("d") and year ("y") suffixes. The # replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped # block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now). # anymore (locktimes are always UNIX timestamps now). The helper moved to
from bal.gui.qt.widgets import LockTimeRawEdit # bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
assert LockTimeRawEdit.replace_str("123d") == "123" from bal.core.input_rules import replace_dy_suffixes
assert LockTimeRawEdit.replace_str("456y") == "456" assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix) # "b" is left untouched (no longer a recognised suffix)
assert LockTimeRawEdit.replace_str("789b") == "789b" assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains # only d/y are stripped; a stray "b" remains
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b" assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_locktime_raw_edit_checkbdy(): def test_locktime_raw_edit_checkbdy():
from bal.gui.qt.widgets import LockTimeRawEdit # checkbdy moved to bal.core.input_rules (_checkbdy).
from bal.core.input_rules import _checkbdy
# character at expected position matches appendix # character at expected position matches appendix
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d") pos, s = _checkbdy("123d", 4, "d")
assert s == "123d" assert s == "123d"
# character at expected position does not match # character at expected position does not match
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d") pos, s = _checkbdy("123x", 4, "d")
assert s == "123x" assert s == "123x"

View File

@@ -305,10 +305,14 @@ def _make_merge_fake(willitems):
wallet=FakeWallet(), wallet=FakeWallet(),
bal_window=None, bal_window=None,
date_to_check=1700000000, date_to_check=1700000000,
will_settings={"threshold": 1700000000},
bal_plugin=SimpleNamespace( bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace( HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})" get=lambda: "BAL will history ({willexecutor})"
) ),
# BASIC is the default user type; merge_will resolves date_to_check
# through bal.core.checkalive when it is missing.
is_basic_mode=lambda: True,
), ),
update_all=lambda: calls.append("update_all"), update_all=lambda: calls.append("update_all"),
) )