core: anchor relative locktime/threshold recipes to the built tx locktime so an unchanged will no longer reads as expired/postponed; stop the daily invalidate prompt (karen7)
This commit is contained in:
@@ -29,7 +29,10 @@ class CheckAliveError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_date_to_check(
|
def resolve_date_to_check(
|
||||||
is_basic_mode: bool, will_settings: Any, now: float | None = None
|
is_basic_mode: bool,
|
||||||
|
will_settings: Any,
|
||||||
|
now: float | None = None,
|
||||||
|
built_locktime: float | int | None = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""Return the reference timestamp for every will-validity check.
|
"""Return the reference timestamp for every will-validity check.
|
||||||
|
|
||||||
@@ -41,19 +44,56 @@ def resolve_date_to_check(
|
|||||||
govern those checks. ``date_to_check`` is set to *now*: every check is
|
govern those checks. ``date_to_check`` is set to *now*: every check is
|
||||||
evaluated against the current moment (the Check Alive effectively does not
|
evaluated against the current moment (the Check Alive effectively does not
|
||||||
exist) while the delivery locktime is still fully enforced.
|
exist) while the delivery locktime is still fully enforced.
|
||||||
* ADVANCED mode: the user-controlled stored threshold is used as-is.
|
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
|
||||||
|
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
|
||||||
|
means "N days BEFORE the delivery date" and is resolved against the stored
|
||||||
|
locktime (matching the date the settings widget displays), so it stays in
|
||||||
|
lockstep with the built transactions instead of drifting with the clock.
|
||||||
|
|
||||||
|
A RELATIVE stored locktime is resolved against the frozen delivery date of
|
||||||
|
the built will (``built_locktime``, the locktime inside the signed tx) when
|
||||||
|
one exists: the will's real delivery date is authoritative, and resolving
|
||||||
|
the relative locktime from *now* would drift ``date_to_check`` past the
|
||||||
|
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
|
||||||
|
invalidate) every day. Without a built will the legacy forward-from-now
|
||||||
|
resolution is kept.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
||||||
will_settings: the per-wallet settings dict (``"threshold"`` key).
|
will_settings: the per-wallet settings dict (``"threshold"`` and
|
||||||
|
``"locktime"`` keys).
|
||||||
now: overridable clock for tests; defaults to ``datetime.now()``.
|
now: overridable clock for tests; defaults to ``datetime.now()``.
|
||||||
|
built_locktime: the absolute locktime frozen inside the built will's
|
||||||
|
transactions (``None`` when there is no built will yet).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The reference timestamp (float, UNIX seconds).
|
The reference timestamp (float, UNIX seconds).
|
||||||
"""
|
"""
|
||||||
if is_basic_mode:
|
if is_basic_mode:
|
||||||
return (now if now is not None else datetime.now().timestamp())
|
return (now if now is not None else datetime.now().timestamp())
|
||||||
return BalTimestamp(will_settings["threshold"]).to_timestamp()
|
|
||||||
|
threshold = BalTimestamp(will_settings["threshold"])
|
||||||
|
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
||||||
|
# the settings widget resolves it as real_threshold = locktime - N days.
|
||||||
|
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
|
||||||
|
# date_to_check into a moving target that disagrees with the fixed
|
||||||
|
# locktime of the built transactions (and with the date shown in the UI),
|
||||||
|
# which can wrongly mark the will as expired/postponed. Resolve it
|
||||||
|
# against the delivery date instead.
|
||||||
|
if threshold.unit is not None:
|
||||||
|
locktime_raw = will_settings.get("locktime")
|
||||||
|
if locktime_raw is None:
|
||||||
|
# No delivery reference to anchor to: fall back to the legacy
|
||||||
|
# forward-from-now resolution.
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
|
||||||
|
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
|
||||||
|
# will has already been built, its frozen delivery date (the tx
|
||||||
|
# locktime) is the authoritative anchor (see docstring).
|
||||||
|
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
||||||
|
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
|
||||||
|
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
|
||||||
|
|
||||||
def check_alive_expired(
|
def check_alive_expired(
|
||||||
|
|||||||
@@ -131,6 +131,72 @@ class Util:
|
|||||||
+ days * 60 * 60 * 24
|
+ days * 60 * 60 * 24
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relative_days(value):
|
||||||
|
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
|
||||||
|
|
||||||
|
Returns ``None`` when the value is not a relative recipe (an absolute
|
||||||
|
timestamp, a plain number, or garbage).
|
||||||
|
"""
|
||||||
|
s = str(value)
|
||||||
|
if s and s[-1] in "yYdD":
|
||||||
|
try:
|
||||||
|
n = int(s[:-1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return n * 365 if s[-1] in "yY" else n
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_locktime_against_tx(current, built, tx_locktime):
|
||||||
|
"""Resolve a locktime recipe against the moment the signed tx was built.
|
||||||
|
|
||||||
|
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
|
||||||
|
target: parsing it against *now* on every check drifts it one day per
|
||||||
|
day away from the fixed locktime frozen inside the signed Bitcoin
|
||||||
|
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
|
||||||
|
plugin asks to invalidate it every day (reported bug). This resolves
|
||||||
|
the current recipe against the build moment instead, recovered from the
|
||||||
|
signed transaction's locktime and the recipe that was actually frozen
|
||||||
|
at build time (``built``, the value stored in the will item):
|
||||||
|
|
||||||
|
build_moment = tx_locktime - duration(built)
|
||||||
|
expected = build_moment + duration(current)
|
||||||
|
|
||||||
|
An unchanged recipe therefore resolves to exactly ``tx_locktime``
|
||||||
|
(coherent), a lengthened one resolves later (postpone) and a shortened
|
||||||
|
one earlier (anticipate).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: the current locktime recipe (relative or absolute).
|
||||||
|
built: the recipe frozen at build time (stored in the will item).
|
||||||
|
tx_locktime: the absolute locktime frozen inside the signed tx.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: the resolved absolute locktime (UNIX timestamp).
|
||||||
|
"""
|
||||||
|
current_days = Util._relative_days(current)
|
||||||
|
built_days = Util._relative_days(built)
|
||||||
|
if current_days is None:
|
||||||
|
# Absolute current date: compare directly against the frozen tx.
|
||||||
|
try:
|
||||||
|
return int(current)
|
||||||
|
except Exception:
|
||||||
|
return Util.parse_locktime_string(current)
|
||||||
|
if built_days is None or not tx_locktime:
|
||||||
|
# The stored recipe was absolute (a fixed date) or the tx has no
|
||||||
|
# usable locktime: there is no relative anchor to recover the build
|
||||||
|
# moment, so fall back to the legacy forward-from-now resolution.
|
||||||
|
return Util.parse_locktime_string(current)
|
||||||
|
try:
|
||||||
|
base = datetime.fromtimestamp(int(tx_locktime)).replace(
|
||||||
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
build_moment = base - timedelta(days=built_days)
|
||||||
|
return int((build_moment + timedelta(days=current_days)).timestamp())
|
||||||
|
except Exception:
|
||||||
|
return Util.parse_locktime_string(current)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Amount helpers
|
# Amount helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -1144,8 +1144,6 @@ class Will:
|
|||||||
if heir := heirs.get(wheir, None):
|
if heir := heirs.get(wheir, None):
|
||||||
|
|
||||||
if heir[0] == their[0] and heir[1] == their[1]:
|
if heir[0] == their[0] and heir[1] == their[1]:
|
||||||
# The requested (possibly new) locktime for this heir.
|
|
||||||
new_locktime = Util.parse_locktime_string(heir[2])
|
|
||||||
# IMPORTANT: compare against the locktime that is
|
# IMPORTANT: compare against the locktime that is
|
||||||
# actually frozen inside the already-signed Bitcoin
|
# actually frozen inside the already-signed Bitcoin
|
||||||
# transaction (w.tx.locktime), NOT against their[2].
|
# transaction (w.tx.locktime), NOT against their[2].
|
||||||
@@ -1156,6 +1154,16 @@ class Will:
|
|||||||
# undetected. w.tx.locktime is immutable once signed
|
# undetected. w.tx.locktime is immutable once signed
|
||||||
# and is exactly what the will-executors hold.
|
# and is exactly what the will-executors hold.
|
||||||
tx_locktime = int(w.tx.locktime)
|
tx_locktime = int(w.tx.locktime)
|
||||||
|
# The requested (possibly new) locktime for this heir.
|
||||||
|
# A RELATIVE recipe ("1y"/"30d") is resolved against
|
||||||
|
# the moment the signed tx was built, NOT against now:
|
||||||
|
# re-parsing it from "now" drifts it one day per day
|
||||||
|
# away from the frozen tx locktime, so an UNCHANGED
|
||||||
|
# will would be read as a POSTPONE and the plugin
|
||||||
|
# would ask to invalidate it every day.
|
||||||
|
new_locktime = Util.resolve_locktime_against_tx(
|
||||||
|
heir[2], their[2], tx_locktime
|
||||||
|
)
|
||||||
if new_locktime == tx_locktime:
|
if new_locktime == tx_locktime:
|
||||||
# Unchanged: this heir is still coherent.
|
# Unchanged: this heir is still coherent.
|
||||||
count = heirs_found.get(wheir, 0)
|
count = heirs_found.get(wheir, 0)
|
||||||
|
|||||||
@@ -1012,6 +1012,14 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
desired behaviour, and that the date shown in the panel/wizard must
|
desired behaviour, and that the date shown in the panel/wizard must
|
||||||
reflect this anticipated date (so the calendar .ics also uses it).
|
reflect this anticipated date (so the calendar .ics also uses it).
|
||||||
|
|
||||||
|
RELATIVE dates are additionally normalised here: a relative value
|
||||||
|
("30d"/"1y") is re-parsed against "now" on every check, so it drifts
|
||||||
|
away from the fixed transaction locktime and the postpone check would
|
||||||
|
wrongly ask to invalidate the will every day. The stored locktime is
|
||||||
|
therefore frozen to the built transactions' absolute locktime, and a
|
||||||
|
relative threshold is frozen to its "N days before the delivery"
|
||||||
|
absolute value.
|
||||||
|
|
||||||
We route the update through BalWindow.update_setting_widgets, which is
|
We route the update through BalWindow.update_setting_widgets, which is
|
||||||
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
||||||
persists it to Electrum's database and (3) refreshes the date widgets in
|
persists it to Electrum's database and (3) refreshes the date widgets in
|
||||||
@@ -1024,30 +1032,72 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
if min_locktime is None:
|
if min_locktime is None:
|
||||||
return
|
return
|
||||||
min_locktime = int(min_locktime)
|
min_locktime = int(min_locktime)
|
||||||
|
stored_locktime = self.bal_window.will_settings["locktime"]
|
||||||
|
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
|
||||||
|
# against "now" on every check, so it drifts one day per day away from
|
||||||
|
# the fixed tx locktime and the postpone check would ALWAYS see a
|
||||||
|
# postpone -> the plugin asks to invalidate the will every day. It must
|
||||||
|
# therefore be normalised here to the frozen absolute locktime of the
|
||||||
|
# built transactions, even when it happens to parse to the same moment
|
||||||
|
# today. (Only an absolute stored value is comparable, see below.)
|
||||||
|
is_relative_locktime = (
|
||||||
|
isinstance(stored_locktime, str)
|
||||||
|
and stored_locktime[-1:].lower() in ("d", "y")
|
||||||
|
)
|
||||||
# Current stored delivery date, as a comparable UNIX timestamp.
|
# Current stored delivery date, as a comparable UNIX timestamp.
|
||||||
try:
|
try:
|
||||||
current = int(
|
current = int(Util.parse_locktime_string(stored_locktime))
|
||||||
|
except Exception:
|
||||||
|
# If the stored value cannot be parsed, fall back to syncing.
|
||||||
|
current = None
|
||||||
|
# A genuine user-chosen POSTPONE (a later absolute date) is never
|
||||||
|
# overwritten; anything else is synced to the built transactions.
|
||||||
|
was_anticipation = current is not None and min_locktime < current
|
||||||
|
if not is_relative_locktime and current is not None and not was_anticipation:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_logger.debug(
|
||||||
|
f"sync delivery date to built tx locktime: "
|
||||||
|
f"{current} -> {min_locktime}"
|
||||||
|
)
|
||||||
|
# Remember that we anticipated the date, so the later sign prompt can
|
||||||
|
# explain WHY signing is needed (see on_success_phase1). A pure
|
||||||
|
# relative->absolute normalisation is NOT an anticipation.
|
||||||
|
if was_anticipation:
|
||||||
|
self._date_was_anticipated = True
|
||||||
|
# update_setting_widgets stores the value, persists it and refreshes
|
||||||
|
# the date widgets in all panels/wizard (the .ics calendar too).
|
||||||
|
self.bal_window.update_setting_widgets(
|
||||||
|
min_locktime, "locktime", update_all=True
|
||||||
|
)
|
||||||
|
# Same moving-target problem for a relative "Check Alive" threshold:
|
||||||
|
# it means "N days BEFORE the delivery" (the settings widget resolves it
|
||||||
|
# as real_threshold = locktime - N days), so it is normalised to that
|
||||||
|
# absolute date, referenced against the now-absolute stored locktime.
|
||||||
|
threshold_raw = self.bal_window.will_settings.get("threshold")
|
||||||
|
if (
|
||||||
|
isinstance(threshold_raw, str)
|
||||||
|
and threshold_raw[-1:].lower() in ("d", "y")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
locktime_ts = int(
|
||||||
Util.parse_locktime_string(
|
Util.parse_locktime_string(
|
||||||
self.bal_window.will_settings["locktime"]
|
self.bal_window.will_settings["locktime"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
real_threshold = int(
|
||||||
# If the stored value cannot be parsed, fall back to syncing.
|
BalTimestamp(threshold_raw)
|
||||||
current = None
|
.to_date(locktime_ts, reverse=True)
|
||||||
# Only anticipate (move the date EARLIER); never overwrite a postpone.
|
.timestamp()
|
||||||
if current is not None and min_locktime >= current:
|
)
|
||||||
return
|
except Exception as e:
|
||||||
_logger.debug(
|
_logger.error(f"sync threshold to absolute failed: {e}")
|
||||||
f"sync delivery date to anticipated tx locktime: "
|
else:
|
||||||
f"{current} -> {min_locktime}"
|
_logger.debug(
|
||||||
|
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
|
||||||
)
|
)
|
||||||
# Remember that we anticipated the date, so the later sign prompt can
|
|
||||||
# explain WHY signing is needed (see on_success_phase1).
|
|
||||||
self._date_was_anticipated = True
|
|
||||||
# update_setting_widgets stores the value, persists it and refreshes the
|
|
||||||
# date widgets in all panels/wizard (so the .ics calendar uses it too).
|
|
||||||
self.bal_window.update_setting_widgets(
|
self.bal_window.update_setting_widgets(
|
||||||
min_locktime, "locktime", update_all=True
|
real_threshold, "threshold", update_all=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_accept(self):
|
def on_accept(self):
|
||||||
|
|||||||
@@ -640,7 +640,9 @@ class BalWindow:
|
|||||||
# exactly as before. The policy itself lives in
|
# exactly as before. The policy itself lives in
|
||||||
# ``bal.core.checkalive.resolve_date_to_check``.
|
# ``bal.core.checkalive.resolve_date_to_check``.
|
||||||
self.date_to_check = resolve_date_to_check(
|
self.date_to_check = resolve_date_to_check(
|
||||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
self.bal_plugin.is_basic_mode(),
|
||||||
|
self.will_settings,
|
||||||
|
built_locktime=Will.get_min_locktime(self.willitems),
|
||||||
)
|
)
|
||||||
# found = False
|
# found = False
|
||||||
# NOTE: block-height tracking removed (A1) - locktimes are always
|
# NOTE: block-height tracking removed (A1) - locktimes are always
|
||||||
|
|||||||
@@ -59,12 +59,95 @@ def test_advanced_mode_uses_threshold_absolute():
|
|||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_parses_relative_threshold():
|
def test_advanced_mode_parses_relative_threshold():
|
||||||
# "30d" resolves to a future timestamp (midnight-normalised).
|
# A relative threshold means "N days BEFORE the delivery": it resolves
|
||||||
|
# against the stored locktime (backwards), not forward from now.
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
locktime = fake_now + 90 * 86400
|
||||||
|
settings = {"threshold": "30d", "locktime": locktime}
|
||||||
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
|
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
||||||
|
expected = (datetime.fromtimestamp(locktime)
|
||||||
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
- timedelta(days=30)).timestamp()
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
# 90d delivery with a 30d window: the window starts 60 days after now.
|
||||||
|
assert result > fake_now
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
||||||
|
"""A relative threshold never drifts with the clock: re-resolving it a day
|
||||||
|
later, with the same fixed absolute locktime, yields the same date."""
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
locktime = fake_now + 90 * 86400
|
||||||
|
settings = {"threshold": "30d", "locktime": locktime}
|
||||||
|
first = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
|
# Next day: same stored settings (the fixed delivery), a later clock.
|
||||||
|
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||||
|
"""A relative locktime is resolved against 'now' first, then the relative
|
||||||
|
threshold counts N days back from it (matches the settings widget)."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from bal.core.plugin_base import BalTimestamp
|
||||||
|
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"threshold": "30d", "locktime": "90d"}
|
||||||
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
|
# Recompute the expected value with the same resolution rules:
|
||||||
|
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
||||||
|
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
|
||||||
|
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
assert result > fake_now
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
|
||||||
|
# Without a locktime reference, fall back to the legacy forward resolution.
|
||||||
settings = {"threshold": "30d"}
|
settings = {"threshold": "30d"}
|
||||||
result = resolve_date_to_check(False, settings)
|
result = resolve_date_to_check(False, settings)
|
||||||
assert result > time.time()
|
assert result > time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||||
|
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
||||||
|
delivery date (built_locktime), not to "now": an unchanged will must not
|
||||||
|
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
||||||
|
frozen = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
|
||||||
|
settings = {"threshold": "30d", "locktime": "2y"}
|
||||||
|
# On build day the frozen delivery is authoritative: date_to_check is
|
||||||
|
# frozen - 30d and NEVER drifts, however much later the clock gets.
|
||||||
|
first = resolve_date_to_check(
|
||||||
|
False, settings, now=1_800_000_000.0, built_locktime=frozen
|
||||||
|
)
|
||||||
|
assert abs(first - (frozen - 30 * 86400)) < 1
|
||||||
|
later = resolve_date_to_check(
|
||||||
|
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
|
||||||
|
)
|
||||||
|
assert first == later
|
||||||
|
# The check window must start BEFORE the frozen delivery (never expired).
|
||||||
|
assert first < frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
||||||
|
"""Without a built will there is no anchor: keeps the legacy now-based
|
||||||
|
resolution (a moving target, used only before the first build)."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from bal.core.plugin_base import BalTimestamp
|
||||||
|
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"threshold": "30d", "locktime": "90d"}
|
||||||
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
|
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
|
||||||
|
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# check_alive_expired
|
# check_alive_expired
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -85,6 +85,63 @@ def test_int_locktime():
|
|||||||
assert Util.int_locktime() == 0
|
assert Util.int_locktime() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_days():
|
||||||
|
assert Util._relative_days("30d") == 30
|
||||||
|
assert Util._relative_days("1y") == 365
|
||||||
|
assert Util._relative_days("2y") == 730
|
||||||
|
assert Util._relative_days("30D") == 30
|
||||||
|
assert Util._relative_days(1700000000) is None
|
||||||
|
assert Util._relative_days("1700000000") is None
|
||||||
|
assert Util._relative_days("garbage") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_absolute():
|
||||||
|
"""An absolute current date is returned unchanged (compared vs the tx)."""
|
||||||
|
frozen = 1817438400
|
||||||
|
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
|
||||||
|
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_unchanged_relative():
|
||||||
|
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
|
||||||
|
(coherent), instead of drifting one day per day away from it."""
|
||||||
|
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
|
||||||
|
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
|
||||||
|
assert resolved == frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_lengthened():
|
||||||
|
"""A lengthened relative recipe resolves later than the frozen tx locktime
|
||||||
|
(this is what the postpone check uses to trigger invalidation)."""
|
||||||
|
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
|
||||||
|
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
|
||||||
|
assert resolved == frozen + 365 * 86400
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_shortened():
|
||||||
|
"""A shortened relative recipe resolves earlier than the frozen tx locktime
|
||||||
|
(this is what the anticipate/rebuild path uses)."""
|
||||||
|
frozen = 1817438400
|
||||||
|
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
|
||||||
|
assert resolved < frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_no_relative_anchor():
|
||||||
|
"""When the built recipe was absolute there is no anchor: falls back to the
|
||||||
|
legacy forward-from-now resolution (returns a timestamp, no crash)."""
|
||||||
|
frozen = 1817438400
|
||||||
|
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
assert result > 1700000000
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_zero_tx_locktime():
|
||||||
|
"""A tx with no usable locktime falls back to now-based resolution."""
|
||||||
|
result = Util.resolve_locktime_against_tx("1y", "1y", 0)
|
||||||
|
assert isinstance(result, int)
|
||||||
|
assert result > 1700000000
|
||||||
|
|
||||||
|
|
||||||
def test_encode_decode_amount():
|
def test_encode_decode_amount():
|
||||||
dp = 8 # typical BTC decimal point
|
dp = 8 # typical BTC decimal point
|
||||||
|
|
||||||
@@ -440,6 +497,13 @@ if __name__ == "__main__":
|
|||||||
test_str_to_locktime()
|
test_str_to_locktime()
|
||||||
test_parse_locktime_string()
|
test_parse_locktime_string()
|
||||||
test_int_locktime()
|
test_int_locktime()
|
||||||
|
test_relative_days()
|
||||||
|
test_resolve_locktime_against_tx_absolute()
|
||||||
|
test_resolve_locktime_against_tx_unchanged_relative()
|
||||||
|
test_resolve_locktime_against_tx_lengthened()
|
||||||
|
test_resolve_locktime_against_tx_shortened()
|
||||||
|
test_resolve_locktime_against_tx_no_relative_anchor()
|
||||||
|
test_resolve_locktime_against_tx_zero_tx_locktime()
|
||||||
test_encode_decode_amount()
|
test_encode_decode_amount()
|
||||||
test_is_perc()
|
test_is_perc()
|
||||||
test_cmp_array()
|
test_cmp_array()
|
||||||
|
|||||||
223
tests/test_heir_relative_anchor.py
Normal file
223
tests/test_heir_relative_anchor.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""
|
||||||
|
Tests for the relative-recipe anchoring in the will coherence check.
|
||||||
|
|
||||||
|
Regression for the reported bug: a wallet built with RELATIVE locktimes
|
||||||
|
(``"1y"`` on the heirs, relative will_settings) was asked to invalidate the
|
||||||
|
will EVERY DAY. The relative recipes were re-parsed against *now* on every
|
||||||
|
check, so they drifted one day per day away from the fixed locktime frozen
|
||||||
|
inside the signed transaction and the check mistook the (unchanged) will for a
|
||||||
|
POSTPONE / EXPIRED one.
|
||||||
|
|
||||||
|
The two gates that produced the prompt are covered here:
|
||||||
|
|
||||||
|
1. ``Will.check_willexecutors_and_heirs`` must treat an UNCHANGED relative
|
||||||
|
recipe as coherent (resolved against the build moment, not "now"), while
|
||||||
|
still detecting a genuinely lengthened recipe as a postpone.
|
||||||
|
2. ``resolve_date_to_check`` (ADVANCED mode) must anchor a relative stored
|
||||||
|
locktime to the built transactions' frozen delivery date, so the will is
|
||||||
|
never read as EXPIRED because the check window drifts past the frozen
|
||||||
|
tx locktime.
|
||||||
|
|
||||||
|
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
|
||||||
|
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
|
||||||
|
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
|
||||||
|
``{"locktime": "2y", "threshold": "150d"}``.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
|
python3 tests/test_heir_relative_anchor.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum import constants # noqa: E402 (path insert above)
|
||||||
|
|
||||||
|
constants.net = constants.BitcoinRegtest
|
||||||
|
|
||||||
|
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
||||||
|
from bal.core.will import ( # noqa: E402
|
||||||
|
HeirNotFoundException,
|
||||||
|
NoHeirsException,
|
||||||
|
NotCompleteWillException,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
WillPostponedException,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
|
||||||
|
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
|
||||||
|
# will built 2026-08-05 with a "1y" recipe.
|
||||||
|
_FROZEN = 1817438400
|
||||||
|
|
||||||
|
|
||||||
|
def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||||
|
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
|
||||||
|
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||||
|
d = {
|
||||||
|
"tx": _VALID_TX_HEX,
|
||||||
|
"heirs": copy.deepcopy(heirs),
|
||||||
|
"willexecutor": None,
|
||||||
|
"status": "",
|
||||||
|
"description": "",
|
||||||
|
"time": 0,
|
||||||
|
"change": "",
|
||||||
|
"baltx_fees": 1,
|
||||||
|
}
|
||||||
|
item = WillItem(d, _id="willid_1")
|
||||||
|
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||||
|
item.tx.locktime = tx_locktime
|
||||||
|
if status_complete:
|
||||||
|
item.set_status("COMPLETE", True)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _run_heir_check(will_heirs, current_heirs, tx_locktime, status_complete):
|
||||||
|
"""Run ``check_willexecutors_and_heirs`` and return the outcome."""
|
||||||
|
item = _make_will_item(will_heirs, tx_locktime, status_complete)
|
||||||
|
will = {"willid_1": item}
|
||||||
|
try:
|
||||||
|
result = Will.check_willexecutors_and_heirs(
|
||||||
|
will, current_heirs, {}, False, 0, 1
|
||||||
|
)
|
||||||
|
return f"coherent ({result})"
|
||||||
|
except WillPostponedException as e:
|
||||||
|
return f"POSTPONE: {e}"
|
||||||
|
except HeirNotFoundException as e:
|
||||||
|
return f"rebuild: {e}"
|
||||||
|
except NoHeirsException as e:
|
||||||
|
return f"NoHeirs: {e}"
|
||||||
|
except NotCompleteWillException as e:
|
||||||
|
return f"NotComplete: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_relative_recipe_signed_is_coherent():
|
||||||
|
"""The reported bug: an unchanged "1y" recipe on a signed will must NOT be
|
||||||
|
read as a postpone just because the clock has advanced past build day."""
|
||||||
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
outcome = _run_heir_check(
|
||||||
|
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
|
||||||
|
)
|
||||||
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_relative_recipe_unsigned_is_coherent():
|
||||||
|
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
outcome = _run_heir_check(
|
||||||
|
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
|
||||||
|
)
|
||||||
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_recipe_lengthened_on_signed_is_postpone():
|
||||||
|
"""A genuinely lengthened recipe ("1y" -> "2y") on a signed/sent will is
|
||||||
|
still detected as a postpone (must invalidate on-chain first)."""
|
||||||
|
built = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
now = {"alice": ["addr_alice", 5000, "2y"]}
|
||||||
|
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
|
||||||
|
assert outcome.startswith("POSTPONE"), outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_recipe_shortened_on_signed_is_rebuild():
|
||||||
|
"""A shortened recipe ("1y" -> "30d") is an ANTICIPATE: plain rebuild, no
|
||||||
|
on-chain invalidation."""
|
||||||
|
built = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
now = {"alice": ["addr_alice", 5000, "30d"]}
|
||||||
|
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
|
||||||
|
assert outcome.startswith("rebuild"), outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_absolute_recipe_is_coherent():
|
||||||
|
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
||||||
|
outcome = _run_heir_check(
|
||||||
|
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
|
||||||
|
)
|
||||||
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_postpone_on_signed_still_detected():
|
||||||
|
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
||||||
|
now = {"alice": ["addr_alice", 5000, str(_FROZEN + 86400)]}
|
||||||
|
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
|
||||||
|
assert outcome.startswith("POSTPONE"), outcome
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# karen7 wallet regression (real fixture)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def _load_karen7():
|
||||||
|
path = os.path.join(os.path.dirname(__file__), "karen7")
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def test_karen7_frozen_delivery_not_expired():
|
||||||
|
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
|
||||||
|
window opens BEFORE the delivery, so the will is never read as expired."""
|
||||||
|
data = _load_karen7()
|
||||||
|
will_settings = data["will_settings"]
|
||||||
|
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
||||||
|
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||||
|
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
||||||
|
assert built_locktime == _FROZEN
|
||||||
|
|
||||||
|
date_to_check = resolve_date_to_check(
|
||||||
|
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
||||||
|
)
|
||||||
|
assert int(date_to_check) < _FROZEN
|
||||||
|
# Re-evaluated 10 days later the window is identical (no daily drift).
|
||||||
|
later = resolve_date_to_check(
|
||||||
|
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
|
||||||
|
built_locktime=built_locktime,
|
||||||
|
)
|
||||||
|
assert date_to_check == later
|
||||||
|
|
||||||
|
|
||||||
|
def test_karen7_unchanged_heirs_are_coherent():
|
||||||
|
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
|
||||||
|
signed tx: the plugin must NOT ask to invalidate the will."""
|
||||||
|
data = _load_karen7()
|
||||||
|
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
||||||
|
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||||
|
date_to_check = resolve_date_to_check(
|
||||||
|
False, data["will_settings"],
|
||||||
|
now=1_800_000_000.0,
|
||||||
|
built_locktime=int(wi.tx.locktime),
|
||||||
|
)
|
||||||
|
outcome = _run_heir_check(
|
||||||
|
data["will"][valid_wid]["heirs"],
|
||||||
|
data["heirs"],
|
||||||
|
int(wi.tx.locktime),
|
||||||
|
status_complete=True,
|
||||||
|
)
|
||||||
|
assert outcome.startswith("coherent"), outcome
|
||||||
|
assert int(date_to_check) < int(wi.tx.locktime)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All heir-relative-anchor tests passed")
|
||||||
191
tests/test_sync_locktime_built_txs.py
Normal file
191
tests/test_sync_locktime_built_txs.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
||||||
|
|
||||||
|
This is the post-build sync that keeps the plugin's stored delivery date
|
||||||
|
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
|
||||||
|
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
|
||||||
|
|
||||||
|
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
|
||||||
|
asks to invalidate the will EVERY DAY. The relative value is re-parsed
|
||||||
|
against "now" on every check, so it drifts one day per day away from the
|
||||||
|
fixed tx locktime and the postpone check always sees a "postpone".
|
||||||
|
|
||||||
|
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
||||||
|
Electrum wallet) by calling it as an unbound method.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
|
python3 tests/test_sync_locktime_built_txs.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
|
||||||
|
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Fakes
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _fake_willitem(tx_locktime):
|
||||||
|
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime."""
|
||||||
|
return SimpleNamespace(
|
||||||
|
tx=SimpleNamespace(locktime=tx_locktime),
|
||||||
|
get_status=lambda name: True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dialog(will_settings, tx_locktimes, recorded):
|
||||||
|
"""Build a fake dialog ``self`` for _sync_locktime_to_built_txs."""
|
||||||
|
|
||||||
|
def update_setting_widgets(new_value, field, update_all=False):
|
||||||
|
will_settings[field] = new_value
|
||||||
|
recorded.append((field, new_value, update_all))
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
bal_window=SimpleNamespace(
|
||||||
|
willitems={
|
||||||
|
f"tx{i}": _fake_willitem(lt) for i, lt in enumerate(tx_locktimes)
|
||||||
|
},
|
||||||
|
will_settings=will_settings,
|
||||||
|
update_setting_widgets=update_setting_widgets,
|
||||||
|
),
|
||||||
|
_date_was_anticipated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_sync(will_settings, tx_locktimes, recorded):
|
||||||
|
fake = _make_dialog(will_settings, tx_locktimes, recorded)
|
||||||
|
BalBuildWillDialog._sync_locktime_to_built_txs(fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Tests
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_relative_locktime_normalized_to_absolute():
|
||||||
|
"""The reported bug: a relative stored locktime is frozen to the absolute
|
||||||
|
value of the built transaction, even when it parses to the same moment."""
|
||||||
|
tx_locktime = 1_800_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
|
)
|
||||||
|
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
||||||
|
assert fake.bal_window.will_settings["locktime"] != "90d"
|
||||||
|
# A pure relative->absolute normalisation is NOT an anticipation: the sign
|
||||||
|
# prompt must not claim the date was anticipated.
|
||||||
|
assert fake._date_was_anticipated is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_threshold_frozen_to_absolute():
|
||||||
|
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
|
||||||
|
same absolute value the settings widget computes (real_threshold)."""
|
||||||
|
tx_locktime = 1_800_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
|
)
|
||||||
|
expected = int(
|
||||||
|
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
|
||||||
|
)
|
||||||
|
assert fake.bal_window.will_settings["threshold"] == expected
|
||||||
|
assert ("threshold", expected, True) in recorded
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_locktime_unchanged_on_equal():
|
||||||
|
"""An absolute stored locktime that already matches the built txs is left
|
||||||
|
untouched (no spurious rewrite)."""
|
||||||
|
tx_locktime = 1_800_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": tx_locktime, "threshold": 1_700_000_000},
|
||||||
|
[tx_locktime],
|
||||||
|
recorded,
|
||||||
|
)
|
||||||
|
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
||||||
|
assert fake._date_was_anticipated is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_anticipation_sets_flag_and_moves_earlier():
|
||||||
|
"""A real anticipation (built locktime earlier than the stored absolute
|
||||||
|
one) still moves the date earlier and flags the sign prompt."""
|
||||||
|
tx_locktime = 1_700_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
|
||||||
|
[tx_locktime],
|
||||||
|
recorded,
|
||||||
|
)
|
||||||
|
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
||||||
|
assert fake._date_was_anticipated is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_earlier_than_built_never_moved_later():
|
||||||
|
"""A stored absolute date that is already EARLIER than the built txs (the
|
||||||
|
user moved the delivery later) is never pulled back up on rebuild: only
|
||||||
|
anticipation (built < stored) and relative normalisation move the value."""
|
||||||
|
stored = 1_800_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": stored, "threshold": 1_700_000_000},
|
||||||
|
[1_900_000_000],
|
||||||
|
recorded,
|
||||||
|
)
|
||||||
|
assert fake.bal_window.will_settings["locktime"] == stored
|
||||||
|
assert fake._date_was_anticipated is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_txs_uses_minimum_locktime():
|
||||||
|
"""When several transactions carry different locktimes, the minimum is used
|
||||||
|
(owner-confirmed behaviour for the delivery date shown in the UI)."""
|
||||||
|
min_locktime = 1_750_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": "90d", "threshold": "30d"},
|
||||||
|
[min_locktime, min_locktime + 86_400],
|
||||||
|
recorded,
|
||||||
|
)
|
||||||
|
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_locktime_stops_daily_postpone():
|
||||||
|
"""End-to-end guard for the reported bug: after the sync, re-parsing the
|
||||||
|
stored (now absolute) locktime on later days always equals the built
|
||||||
|
tx locktime, so the postpone check never fires again."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
|
tx_locktime = 1_800_000_000
|
||||||
|
recorded = []
|
||||||
|
fake = _call_sync(
|
||||||
|
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
||||||
|
)
|
||||||
|
stored = fake.bal_window.will_settings["locktime"]
|
||||||
|
for _day in range(0, 7):
|
||||||
|
# Simulate the check on later days: parse the STORED value (which is
|
||||||
|
# now the absolute tx locktime) and compare with the fixed tx locktime.
|
||||||
|
new_locktime = Util.parse_locktime_string(stored)
|
||||||
|
assert new_locktime == tx_locktime
|
||||||
|
assert new_locktime <= tx_locktime # no POSTPONE / drift
|
||||||
|
# Sanity: a RELATIVE value would have drifted past it (the bug).
|
||||||
|
drifted = int(
|
||||||
|
(
|
||||||
|
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
||||||
|
).timestamp()
|
||||||
|
)
|
||||||
|
assert drifted > tx_locktime
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All sync_locktime_built_txs tests passed.")
|
||||||
Reference in New Issue
Block a user