Files
bal-electrum-plugin/tests/test_anticipate_past_locktime.py
donkey-ai dc166d04ff feat(bal): Group A (timestamps + statuses + anticipate docs) and Group B (auto-sign) v0.3.4
Bump plugin version to 0.3.4 (manifest, __init__, plugin_base, VERSION).

GROUP A
- A1: remove block-height locktimes; the plugin now uses UNIX timestamps
  only. The NLOCKTIME_BLOCKHEIGHT_MAX guard is kept on purpose (it forces
  every locktime to be a timestamp). chk_locktime is now 2-arg; int_locktime
  and anticipate_locktime no longer accept blocks; RAW input only accepts d/y.
  Two now-dormant configs (LOCKTIME_BLOCKS, LOCKTIMEDELTA_BLOCKS) are kept with
  comments to avoid touching persisted keys.
- A2: rename PENDING -> MEMPOOL everywhere (label 'Mempool', yellow #ffce30);
  add new UPDATED status; ANTICIPATED & UPDATED keep VALID; documented
  set_status rules; backward-compat migration (old PENDING -> MEMPOOL).
- A3: clarify that anticipating to a future date only rebuilds (never
  invalidates), while only a past locktime invalidates (WillExpired). Code was
  already correct; only the comment and docs were fixed.

Colour follow-up: UPDATED lightened from #800080 to #b266b2 (more readable),
updated in theme.py, docs and the theme test.

GROUP B
- B1: verified the 'Create your will' button already opens the guided wizard
  (no code change needed).
- B2: new persisted AUTO_SIGN setting (default ON) with an 'Auto-sign on Check'
  checkbox in the settings dialog. When enabled, Check signs and broadcasts
  automatically; the wallet password is requested only for encrypted wallets.

B2 follow-up (fixes reported after testing):
- Remove the duplicate sign/broadcast cycle in lists.check(); build_will_task()
  already signs and broadcasts.
- Suppress the manual 'press Sign/Broadcast' hint and its popup when AUTO_SIGN
  is ON (kept when OFF).
- Make broadcast one-shot: removed the retry flag and the Exception('retry');
  failed will-executors stay PUSH_FAIL and are skipped (no endless retry).
  PUSHED transactions are already excluded from re-collection.

Docs: inheritance-options.md/.html and inheritance-flow.svg updated to v0.3.4.
Tests: 206 passing (new test_anticipate_manual_locktime, test_anticipate_past_locktime,
test_group_b_auto_sign; updated core/util, core/will_extra, gui/theme, gui/widgets).
CHANGELOG.md added with one numbered entry per task.
2026-06-28 23:00:23 -04:00

122 lines
5.2 KiB
Python

"""
Focused regression/diagnostic test for the "anticipate near expiry" edge case.
Scenario reported by the plugin owner
-------------------------------------
When the user adds funds to the wallet, the plugin must rebuild the inheritance
transaction. To make the new transaction supersede the old one, the plugin
anticipates the locktime (moves it EARLIER) by a fixed amount (1 day), so the
new tx confirms before the old one and the old one gets invalidated.
That logic is fine when the will is far from its locktime. But if the will is
about to expire (e.g. locktime is only a few HOURS in the future), anticipating
by a full day pushes the new locktime INTO THE PAST.
This test verifies, against the real code, what value the anticipation logic
produces in that situation, and whether any guard prevents a past locktime.
It is a *diagnostic* test: it documents the current behaviour so we can decide
whether a fix is needed. Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_anticipate_past_locktime.py -q
"""
import sys
import os
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import Util, LOCKTIME_THRESHOLD
# ---------------------------------------------------------------------------
# 1. Pure-function level: Util.anticipate_locktime has no "now" lower bound.
# ---------------------------------------------------------------------------
def test_anticipate_locktime_can_fall_into_the_past():
"""A timestamp locktime only 10 hours away, anticipated by 1 day, lands
~14 hours in the PAST. The only clamp in anticipate_locktime is `out < 1`,
so a past-but-positive timestamp is returned unchanged."""
now = int(time.time())
locktime_in_10h = now + 10 * 3600 # expires in 10 hours
assert locktime_in_10h > LOCKTIME_THRESHOLD # it is a timestamp locktime
anticipated = int(Util.anticipate_locktime(locktime_in_10h, days=1))
# The anticipated locktime is earlier than the original (as intended)...
assert anticipated < locktime_in_10h
# ...but it is now in the PAST relative to "now":
assert anticipated < now, (
f"anticipated={anticipated} is not in the past relative to now={now}; "
"the edge case may have been fixed"
)
# And crucially it is NOT clamped to anything sensible like `now`; it is
# exactly original - 86400.
assert anticipated == locktime_in_10h - 86400
def test_anticipate_locktime_far_from_expiry_stays_in_future():
"""Control case: when the will is far from expiry (e.g. 30 days away),
anticipating by 1 day keeps the locktime safely in the future."""
now = int(time.time())
locktime_in_30d = now + 30 * 86400
anticipated = int(Util.anticipate_locktime(locktime_in_30d, days=1))
assert anticipated < locktime_in_30d
assert anticipated > now # still in the future -> safe
# ---------------------------------------------------------------------------
# 2. chk_locktime confirms the produced value is considered "expired".
# ---------------------------------------------------------------------------
def test_past_anticipated_locktime_is_seen_as_expired():
"""The anticipated past locktime, when fed to chk_locktime against the
current time, is reported as NOT in the future (i.e. already expired)."""
now = int(time.time())
locktime_in_10h = now + 10 * 3600
anticipated = int(Util.anticipate_locktime(locktime_in_10h, days=1))
# chk_locktime signature is now (timestamp_to_check, locktime) (A1):
# it returns True only if the locktime is still in the future.
in_future = Util.chk_locktime(now, anticipated)
assert in_future is False, (
"the anticipated locktime is unexpectedly still in the future"
)
# ---------------------------------------------------------------------------
# 3. Boundary scan: at what original distance does anticipation start to
# produce a past locktime? Documents the 24h threshold explicitly.
# ---------------------------------------------------------------------------
def test_boundary_is_exactly_24h():
now = int(time.time())
# Just under 24h away -> anticipated into the past.
just_under = now + 24 * 3600 - 60
a1 = int(Util.anticipate_locktime(just_under, days=1))
assert a1 < now
# Just over 24h away -> anticipated value stays (barely) in the future.
just_over = now + 24 * 3600 + 60
a2 = int(Util.anticipate_locktime(just_over, days=1))
assert a2 > now
if __name__ == "__main__":
test_anticipate_locktime_can_fall_into_the_past()
test_anticipate_locktime_far_from_expiry_stays_in_future()
test_past_anticipated_locktime_is_seen_as_expired()
test_boundary_is_exactly_24h()
# Human-readable demonstration
now = int(time.time())
for hours in (10, 23, 25, 48, 24 * 30):
lt = now + hours * 3600
a = int(Util.anticipate_locktime(lt, days=1))
delta_h = (a - now) / 3600.0
verdict = "PAST <-- problem" if a < now else "future (ok)"
print(
f"expires in {hours:>4}h -> anticipated locktime is "
f"{delta_h:+.1f}h from now [{verdict}]"
)
print("[OK] all anticipate-past-locktime diagnostics passed")