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

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
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.
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.QtWidgets import QToolButton
from ...core.reminders import write_temp_ics
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
@@ -57,7 +62,7 @@ class BalCalendarButton(QToolButton):
try:
content = self._ics_provider()
if content:
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
self._calendar_temp_path = write_temp_ics(content)
else:
self._calendar_temp_path = None
self._bal_window.show_warning(
@@ -149,13 +154,6 @@ class BalCalendarButton(QToolButton):
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
def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app")
@@ -184,37 +182,3 @@ class BalCalendar:
if os.path.isdir(desktop):
return desktop
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:`log_error` - format an exception traceback for a dialog.
* :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
@@ -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):
"""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 .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 _, _logger # underscore names are not re-exported by "import *"
from .widgets import (
WillSettingsWidget,
WillWidget,
basic_reminder_offsets,
compute_reminder_offsets,
)
if TYPE_CHECKING:
@@ -1598,7 +1598,7 @@ class BalBuildWillDialog(BalDialog):
def _ics_provider(self):
"""Return the .ics content for the current will data."""
from datetime import datetime, timedelta
from datetime import datetime
try:
locktime_ts = Util.parse_locktime_string(
@@ -1606,22 +1606,25 @@ class BalBuildWillDialog(BalDialog):
)
locktime = datetime.fromtimestamp(locktime_ts)
if self.bal_window.bal_plugin.is_basic_mode():
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
if basic_mode:
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:
threshold_ts = BalTimestamp(
self.bal_window.will_settings["threshold"]
).to_timestamp()
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:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception:
count = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
num_reminders = 3
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1629,56 +1632,21 @@ class BalBuildWillDialog(BalDialog):
for heir in self.bal_window.heirs
)
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)
# ToDo #2: when no reminder falls in the future (the delivery date is
# too close or already passed), build_ics_reminders returns None so
# the caller shows a clear warning instead of producing an empty,
# seemingly-broken .ics file.
return build_ics_reminders(
locktime=locktime,
basic_mode=basic_mode,
description=raw_description,
summary=raw_summary,
wallet_name=str(self.bal_window.wallet),
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:
_logger.error(f"failed to generate .ics: {e}")
return None
@@ -1706,7 +1674,12 @@ class BalBuildWillDialog(BalDialog):
try:
if txs := self.bal_window.sign_transactions(password):
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.msg_set_signing(self.msg_ok())
except Exception as e:

View File

@@ -20,6 +20,13 @@ Contents:
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 .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
@@ -28,86 +35,6 @@ if TYPE_CHECKING:
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):
doubleClicked = pyqtSignal()
@@ -219,39 +146,12 @@ class BalTxFeesWidget(QWidget):
class _LockTimeEditor:
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:
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
class _LockTimeEditor(LockTimeEditor):
"""Qt-side locktime editor base.
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):
@@ -572,51 +472,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.isyears = False
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):
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
self.isdays = False
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"
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
self.isdays = isdays
self.isyears = isyears
self.blockSignals(True)
self.setText(s)
self.blockSignals(False)
@@ -1073,11 +938,12 @@ class WillSettingsWidget(QWidget):
sees several distinct appointments in their calendar.
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
``compute_reminder_offsets``: the offsets are spread uniformly across the
check-alive period and the LAST event always falls one day before the
delivery deadline (locktime). If the period is shorter than the
requested number of reminders, at most one event per day is produced.
capped at 5 by the settings dialog). Their dates are computed by
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
across the check-alive period; the LAST event always falls one day
before the delivery deadline / locktime). If the period is shorter than
the requested number of reminders, at most one event per day is
produced.
Each event:
* 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"
on the Desktop).
"""
now = BalCalendar.format_time(datetime.now())
# locktime = delivery deadline. It is exposed by the date widget as
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
# transaction locktime, so the calendar uses the correct delivery date.
locktime = self.widgets["locktime"].alarm
# BASIC vs ADVANCED reminder strategy.
#
# In ADVANCED mode the reminders are spread uniformly across the
# 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))
)
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})"
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
# reminder falls in the future it returns None (ToDo #2) and the user is
# warned instead of getting an empty-looking file.
ics_content = self._ics_provider()
if not ics_content:
self.bal_window.show_warning(
_(
"No reminders were saved: the delivery date is too "
"close (or already passed)"
)
)
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",
])
return
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
# 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
# plugin no longer tries to open it with a calendar app). The save
@@ -1241,26 +1027,31 @@ class WillSettingsWidget(QWidget):
def _ics_provider(self):
"""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:
locktime = self.widgets["locktime"].alarm
if self.bal_window.bal_plugin.is_basic_mode():
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
if basic_mode:
# 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:
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
days = (locktime - threshold).days
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception:
count = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
num_reminders = 3
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1268,50 +1059,17 @@ class WillSettingsWidget(QWidget):
for heir in self.bal_window.heirs
)
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)
return build_ics_reminders(
locktime=locktime,
basic_mode=basic_mode,
description=raw_description,
summary=raw_summary,
wallet_name=str(self.bal_window.wallet),
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)
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:
_logger.error(f"failed to generate .ics: {e}")
return None
@@ -1354,40 +1112,20 @@ class PercAmountEdit(BTCAmountEdit):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self):
# The text sanitisation lives in bal.core.input_rules.
text = self.text().strip()
if text == "!":
self.shortcut.emit()
return
pos = self.cursorPosition()
chars = "0123456789%"
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 += "%"
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
self.setText(s)
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try:
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
return parse_perc_amount(text, DECIMAL_POINT)
def _get_text_from_amount(self, 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.
"""
import json
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 _, _logger # underscore names are not re-exported by "import *"
from .dialogs import (
@@ -123,7 +131,22 @@ class BalWindow:
for k in keys:
del self.will[k]
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):
_logger.info("********************init_____will____________**********")
@@ -614,11 +637,11 @@ class BalWindow:
# against the current moment, i.e. the Check Alive effectively does
# not exist, while the delivery time (locktime) is still fully
# enforced. ADVANCED mode keeps the user-controlled Check Alive
# exactly as before.
if self.bal_plugin.is_basic_mode():
self.date_to_check = datetime.now().timestamp()
else:
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
# exactly as before. The policy itself lives in
# ``bal.core.checkalive.resolve_date_to_check``.
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
)
# found = False
# NOTE: block-height tracking removed (A1) - locktimes are always
# 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
# check-alive date never forces a postpone/rewrite of the will. The
# delivery time (locktime) is unaffected and still fully enforced.
if (
not self.bal_plugin.is_basic_mode()
and self.date_to_check < datetime.now().timestamp()
# The BASIC/ADVANCED rule lives in
# ``bal.core.checkalive.check_alive_expired``.
if check_alive_expired(
self.bal_plugin.is_basic_mode(), self.date_to_check
):
raise CheckAliveError(self.date_to_check)
@@ -945,7 +969,12 @@ class BalWindow:
targets = Will.only_valid(willitems)
for txid in targets:
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"):
txs[txid] = tx
continue
@@ -1057,7 +1086,10 @@ class BalWindow:
def on_success(txs):
if txs:
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:
self.will[txid] = willitems[txid].to_dict()
try:
@@ -1251,7 +1283,9 @@ class BalWindow:
# very first action in a session). Fall back to "now" so the local
# validity check and the trailing update_all() always have it.
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():
if wid in self.willitems: