forked from bitcoinafterlife/bal-electrum-plugin
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.
This commit is contained in:
180
tests/test_anticipate_manual_locktime.py
Normal file
180
tests/test_anticipate_manual_locktime.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Diagnostic tests for A3 - "Move date earlier (anticipate)" handled manually.
|
||||
|
||||
These tests describe what the core decision function
|
||||
``Will.check_willexecutors_and_heirs`` does TODAY when the user manually sets a
|
||||
SMALLER locktime for an heir (case "A" agreed with the owner):
|
||||
|
||||
* Case A1: the new locktime is smaller than the frozen tx.locktime but STILL
|
||||
in the future (e.g. from "90 days" to "30 days"). Per the owner's decision
|
||||
(D2 = A1) this must lead to a plain REBUILD with the new locktime and must
|
||||
NEVER invalidate on-chain, even if the tx was already signed/sent.
|
||||
|
||||
* Case A2: the new locktime is in the PAST relative to the check date. This is
|
||||
a genuinely expired will and is handled by ``check_will_expired`` ->
|
||||
WillExpiredException -> on-chain invalidation. This behaviour is correct and
|
||||
is kept (D3 only fixes the documentation for case A1).
|
||||
|
||||
These are permanent regression tests. The A3 analysis confirmed the core logic
|
||||
is ALREADY correct: case A1 raises a rebuild signal (a NotCompleteWillException
|
||||
subclass, in practice HeirNotFoundException) and never WillExpiredException, so
|
||||
no on-chain invalidation happens for an anticipate to a future date. The A3 work
|
||||
itself only fixed the documentation (the table wrongly claimed "anticipate ->
|
||||
always invalidate"); these tests guard that the behaviour stays correct.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||
tests/test_anticipate_manual_locktime.py -q
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import copy
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from bal.core.will import ( # noqa: E402
|
||||
WillItem,
|
||||
Will,
|
||||
NotCompleteWillException,
|
||||
WillExpiredException,
|
||||
)
|
||||
|
||||
# A valid serialized tx (1 input + 1 output, version 2).
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
TX_FEES = 100
|
||||
|
||||
|
||||
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 inside the signed tx).
|
||||
|
||||
Args:
|
||||
heirs: The heirs dict stored in the will item.
|
||||
tx_locktime: The locktime to force into the (pretend) signed tx.
|
||||
status_complete: If True, mark the item as already signed (COMPLETE).
|
||||
|
||||
Returns:
|
||||
A configured WillItem.
|
||||
"""
|
||||
d = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": copy.deepcopy(heirs),
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": TX_FEES,
|
||||
}
|
||||
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 _check(will_heirs, current_heirs, tx_locktime,
|
||||
status_complete=False, check_date=0):
|
||||
"""Run check_willexecutors_and_heirs and return the raised exception type
|
||||
(or None if it returned coherent).
|
||||
|
||||
Args:
|
||||
will_heirs: Heirs stored in the will item.
|
||||
current_heirs: The (possibly edited) current heirs dict.
|
||||
tx_locktime: The frozen tx.locktime.
|
||||
status_complete: Whether the will tx is already signed.
|
||||
check_date: The reference check date (timestamp).
|
||||
|
||||
Returns:
|
||||
The exception class raised, or None if the will stayed coherent.
|
||||
"""
|
||||
item = _make_will_item(will_heirs, tx_locktime, status_complete)
|
||||
will = {"willid_1": item}
|
||||
try:
|
||||
Will.check_willexecutors_and_heirs(
|
||||
will, current_heirs, {}, False, check_date, TX_FEES,
|
||||
)
|
||||
return None
|
||||
except Exception as e: # noqa: BLE001 - we want the type for the diagnosis
|
||||
return type(e)
|
||||
|
||||
|
||||
# A locktime far in the future (year ~2030) so it is never "in the past".
|
||||
_FUTURE = 1900000000
|
||||
# An even later future locktime (postpone target).
|
||||
_LATER = 2000000000
|
||||
# A smaller-but-still-future locktime (anticipate target, case A1).
|
||||
_SMALLER_FUTURE = 1800000000
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Case A1: smaller locktime, still in the future.
|
||||
# Owner decision D2 = A1: must REBUILD with the new locktime, NEVER invalidate.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_a1_anticipate_unsigned_triggers_rebuild_not_invalidate():
|
||||
"""Anticipate (smaller, future locktime) on an UNSIGNED will must signal a
|
||||
rebuild (a NotCompleteWillException subclass) and must NOT raise
|
||||
WillExpiredException (which would invalidate on-chain)."""
|
||||
will_heirs = {"alice": ["addr_alice", 5000, str(_FUTURE)]}
|
||||
current_heirs = {"alice": ["addr_alice", 5000, str(_SMALLER_FUTURE)]}
|
||||
raised = _check(will_heirs, current_heirs, tx_locktime=_FUTURE,
|
||||
status_complete=False, check_date=0)
|
||||
# Must NOT be an expiry/invalidation.
|
||||
assert raised is not WillExpiredException
|
||||
# Must be a rebuild signal (HeirChange / HeirNotFound, both subclasses of
|
||||
# NotCompleteWillException).
|
||||
assert raised is not None and issubclass(raised, NotCompleteWillException)
|
||||
|
||||
|
||||
def test_a1_anticipate_signed_triggers_rebuild_not_invalidate():
|
||||
"""Anticipate (smaller, future locktime) on a SIGNED will must STILL signal
|
||||
a rebuild and must NOT invalidate on-chain (owner decision D2 = A1)."""
|
||||
will_heirs = {"alice": ["addr_alice", 5000, str(_FUTURE)]}
|
||||
current_heirs = {"alice": ["addr_alice", 5000, str(_SMALLER_FUTURE)]}
|
||||
raised = _check(will_heirs, current_heirs, tx_locktime=_FUTURE,
|
||||
status_complete=True, check_date=0)
|
||||
assert raised is not WillExpiredException
|
||||
assert raised is not None and issubclass(raised, NotCompleteWillException)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Case A2: smaller locktime that lands in the PAST -> genuine expiry.
|
||||
# This is handled by check_will_expired (separate from check_willexecutors_and_heirs)
|
||||
# and is intentionally NOT changed by A3.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_a2_past_locktime_is_genuinely_expired():
|
||||
"""A locktime in the PAST (relative to the check date) is a genuine expiry
|
||||
handled by check_will_expired -> WillExpiredException. This is kept."""
|
||||
item = _make_will_item(
|
||||
{"alice": ["addr_alice", 5000, str(1000)]}, tx_locktime=1000,
|
||||
)
|
||||
item.set_status("VALID", True)
|
||||
will = {"willid_1": item}
|
||||
all_inputs = Will.get_all_inputs(will, only_valid=True)
|
||||
min_lt = Will.get_all_inputs_min_locktime(all_inputs)
|
||||
# check_date well after the tx locktime -> expired.
|
||||
with pytest.raises(WillExpiredException):
|
||||
Will.check_will_expired(min_lt, timestamp_to_check=_FUTURE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All A3 anticipate diagnostic tests passed")
|
||||
121
tests/test_anticipate_past_locktime.py
Normal file
121
tests/test_anticipate_past_locktime.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
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")
|
||||
@@ -13,6 +13,8 @@ import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
import pytest
|
||||
|
||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
||||
|
||||
|
||||
@@ -32,10 +34,14 @@ def test_locktime_to_str():
|
||||
|
||||
|
||||
def test_str_to_locktime():
|
||||
# relative suffixes pass through
|
||||
# relative suffixes pass through (only days "d" and years "y" are supported)
|
||||
assert Util.str_to_locktime("30d") == "30d"
|
||||
assert Util.str_to_locktime("1y") == "1y"
|
||||
assert Util.str_to_locktime("144b") == "144b"
|
||||
|
||||
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
|
||||
# relative locktime, so it is NOT passed through unchanged.
|
||||
with pytest.raises(Exception):
|
||||
Util.str_to_locktime("144b")
|
||||
|
||||
# integer string -> int
|
||||
assert isinstance(Util.str_to_locktime("500000"), int)
|
||||
@@ -69,12 +75,12 @@ def test_parse_locktime_string():
|
||||
|
||||
|
||||
def test_int_locktime():
|
||||
# int_locktime no longer accepts a "blocks" argument (A1): locktimes are
|
||||
# always expressed in seconds (timestamps), never in block counts.
|
||||
assert Util.int_locktime(seconds=1) == 1
|
||||
assert Util.int_locktime(minutes=1) == 60
|
||||
assert Util.int_locktime(hours=1) == 3600
|
||||
assert Util.int_locktime(days=1) == 86400
|
||||
assert Util.int_locktime(blocks=1) == 600
|
||||
assert Util.int_locktime(days=1, blocks=1) == 86400 + 600
|
||||
assert Util.int_locktime() == 0
|
||||
|
||||
|
||||
@@ -223,40 +229,35 @@ def test_get_value_amount():
|
||||
|
||||
|
||||
def test_chk_locktime():
|
||||
# chk_locktime signature is now (timestamp_to_check, locktime) (A1):
|
||||
# block-height handling was removed, locktimes are always timestamps.
|
||||
now_ts = 1700000000
|
||||
now_block = 800000
|
||||
|
||||
# timestamp locktime still in future
|
||||
assert Util.chk_locktime(now_ts, now_block, 1800000000) is True
|
||||
assert Util.chk_locktime(now_ts, 1800000000) is True
|
||||
|
||||
# timestamp locktime in past
|
||||
assert Util.chk_locktime(now_ts, now_block, 1000000000) is False
|
||||
|
||||
# block-height locktime still in future
|
||||
assert Util.chk_locktime(now_ts, now_block, 900000) is True
|
||||
|
||||
# block-height locktime in past
|
||||
assert Util.chk_locktime(now_ts, now_block, 100000) is False
|
||||
assert Util.chk_locktime(now_ts, 1000000000) is False
|
||||
|
||||
|
||||
def test_anticipate_locktime():
|
||||
# block-height style (note: "anticipate" actually adds for block locktimes)
|
||||
result = Util.anticipate_locktime(800000, blocks=100)
|
||||
assert result == 800000 + 100
|
||||
# anticipate_locktime no longer accepts a "blocks" argument (A1):
|
||||
# it only moves a timestamp earlier (by hours/days).
|
||||
|
||||
# timestamp style
|
||||
# timestamp style: anticipating by 1 day moves the locktime earlier
|
||||
ts = 1700000000
|
||||
result = Util.anticipate_locktime(ts, days=1)
|
||||
assert result < ts
|
||||
assert result > 0
|
||||
assert result == ts - 86400
|
||||
|
||||
# overflow handling (Windows-safe)
|
||||
huge = 2**32 - 1 # NLOCKTIME_MAX
|
||||
result = Util.anticipate_locktime(huge, days=1)
|
||||
assert result > 0
|
||||
|
||||
# clamp to minimum 1
|
||||
low = Util.anticipate_locktime(10, blocks=100)
|
||||
# clamp to minimum 1 (anticipating a tiny value never goes below 1)
|
||||
low = Util.anticipate_locktime(10, days=1)
|
||||
assert low >= 1
|
||||
|
||||
|
||||
|
||||
@@ -96,7 +96,9 @@ def test_check_invalidated_confirmed():
|
||||
assert item.get_status("CONFIRMED") is True
|
||||
|
||||
|
||||
def test_check_invalidated_pending():
|
||||
def test_check_invalidated_mempool():
|
||||
# PENDING was renamed to MEMPOOL (A2): a tx seen with height 0 (in the
|
||||
# mempool, not yet mined) is flagged as MEMPOOL.
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
@@ -104,7 +106,65 @@ def test_check_invalidated_pending():
|
||||
"time": 0, "change": "", "baltx_fees": 100})
|
||||
will = {"wid": item}
|
||||
Will.check_invalidated(will, [], wallet)
|
||||
assert item.get_status("PENDING") is True
|
||||
assert item.get_status("MEMPOOL") is True
|
||||
|
||||
|
||||
def test_legacy_pending_migrates_to_mempool():
|
||||
# Backward-compatibility (A2, "Modo B"): a will saved by an older plugin
|
||||
# version stores the flag under the legacy "PENDING" key. Loading it must
|
||||
# carry that flag over to the new "MEMPOOL" status, so nothing is lost.
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100,
|
||||
"PENDING": True})
|
||||
assert item.get_status("MEMPOOL") is True
|
||||
|
||||
|
||||
def test_new_mempool_wins_over_legacy_pending():
|
||||
# If both the new "MEMPOOL" key and the legacy "PENDING" key are present,
|
||||
# the new key wins: an explicit MEMPOOL=False is NOT overridden by a stale
|
||||
# legacy PENDING=True.
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100,
|
||||
"MEMPOOL": False, "PENDING": True})
|
||||
assert item.get_status("MEMPOOL") is False
|
||||
|
||||
|
||||
def test_updated_status_keeps_valid():
|
||||
# A2 rule: setting UPDATED must NOT clear the VALID flag (the tx is replaced
|
||||
# by a new one that keeps the same locktime and same heirs).
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100,
|
||||
"VALID": True})
|
||||
item.set_status("UPDATED", True)
|
||||
assert item.get_status("UPDATED") is True
|
||||
assert item.get_status("VALID") is True
|
||||
|
||||
|
||||
def test_anticipated_status_keeps_valid():
|
||||
# A2 rule: setting ANTICIPATED must NOT clear the VALID flag (anticipating
|
||||
# only moves the locktime 1 day earlier; the tx stays valid).
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100,
|
||||
"VALID": True})
|
||||
item.set_status("ANTICIPATED", True)
|
||||
assert item.get_status("ANTICIPATED") is True
|
||||
assert item.get_status("VALID") is True
|
||||
|
||||
|
||||
def test_mempool_status_clears_valid():
|
||||
# A2 rule (unchanged from old PENDING behaviour): setting MEMPOOL clears the
|
||||
# VALID flag.
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100,
|
||||
"VALID": True})
|
||||
item.set_status("MEMPOOL", True)
|
||||
assert item.get_status("MEMPOOL") is True
|
||||
assert item.get_status("VALID") is False
|
||||
|
||||
|
||||
def test_check_invalidated_invalidated():
|
||||
@@ -129,9 +189,11 @@ def test_check_will():
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100})
|
||||
will = {"wid": item}
|
||||
Will.check_will(will, [], wallet, 100, 9999999999)
|
||||
# should be PENDING (height=0)
|
||||
assert item.get_status("PENDING") is True
|
||||
# check_will signature is now (will, all_utxos, wallet, timestamp_to_check):
|
||||
# block_to_check was removed in A1 (locktimes are always timestamps).
|
||||
Will.check_will(will, [], wallet, 9999999999)
|
||||
# should be MEMPOOL (height=0); PENDING was renamed to MEMPOOL in A2.
|
||||
assert item.get_status("MEMPOOL") is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
155
tests/test_group_b_auto_sign.py
Normal file
155
tests/test_group_b_auto_sign.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Tests for Group B / B2 and its follow-up fixes.
|
||||
|
||||
Covered behaviour:
|
||||
|
||||
* the persisted ``AUTO_SIGN`` configuration key exists and defaults to ON,
|
||||
and can be turned off and read back;
|
||||
* the manual "next step (Sign/Broadcast)" hint is suppressed when AUTO_SIGN
|
||||
is ON (the Building Will dialog already signs and broadcasts), and shown
|
||||
when AUTO_SIGN is OFF (Fix A - no duplicate "press Broadcast" popup);
|
||||
* the broadcast is "one-shot": transactions already marked PUSHED are not
|
||||
collected again for re-sending, so a will-executor that failed before does
|
||||
not cause the successful ones to be re-broadcast (Fix B).
|
||||
|
||||
The Qt classes are not imported (they require PyQt6 + an Electrum window).
|
||||
Instead we reproduce the small decision logic with light-weight fakes, which
|
||||
keeps the tests fast and headless while still verifying the real contract.
|
||||
|
||||
Run:
|
||||
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_b_auto_sign.py -q
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.plugin_base import BalConfig
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Mocks
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeConfig:
|
||||
"""Minimal mock for Electrum's config object (key/value store)."""
|
||||
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._store.get(key, default)
|
||||
|
||||
def set_key(self, key, value, save=True):
|
||||
self._store[key] = value
|
||||
|
||||
|
||||
class FakeWillItem:
|
||||
"""Minimal will item exposing the status flags and will-executor used by
|
||||
``Willexecutors.get_willexecutor_transactions``.
|
||||
|
||||
``statuses`` is a set of active status names; ``we`` is the assigned
|
||||
will-executor dict (or ``None``); ``tx`` is any stringifiable stand-in for
|
||||
the transaction.
|
||||
"""
|
||||
|
||||
def __init__(self, statuses, we, tx="rawtx"):
|
||||
self._statuses = set(statuses)
|
||||
self.we = we
|
||||
self.tx = tx
|
||||
|
||||
def get_status(self, name):
|
||||
return name in self._statuses
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# AUTO_SIGN config default
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_auto_sign_config_defaults_on():
|
||||
"""AUTO_SIGN must default to ON (True) when not yet stored."""
|
||||
cfg = FakeConfig()
|
||||
auto_sign = BalConfig(cfg, "bal_auto_sign", True)
|
||||
assert auto_sign.get() is True
|
||||
|
||||
|
||||
def test_auto_sign_config_can_be_disabled():
|
||||
"""Once turned off and persisted, AUTO_SIGN reads back as False."""
|
||||
cfg = FakeConfig()
|
||||
auto_sign = BalConfig(cfg, "bal_auto_sign", True)
|
||||
auto_sign.set(False)
|
||||
assert auto_sign.get() is False
|
||||
# A fresh wrapper over the same config still sees the stored value.
|
||||
assert BalConfig(cfg, "bal_auto_sign", True).get() is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Fix A - manual "next step" hint suppressed when AUTO_SIGN is ON
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _should_show_manual_hint(auto_sign_on):
|
||||
"""Reproduce the guard added at the top of _show_next_steps_hint().
|
||||
|
||||
Returns True when the manual Sign/Broadcast hint (and follow-up popup)
|
||||
should be shown. With AUTO_SIGN ON the dialog has already signed and
|
||||
broadcast, so the hint must be suppressed.
|
||||
"""
|
||||
if auto_sign_on:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def test_hint_suppressed_when_auto_sign_on():
|
||||
assert _should_show_manual_hint(auto_sign_on=True) is False
|
||||
|
||||
|
||||
def test_hint_shown_when_auto_sign_off():
|
||||
assert _should_show_manual_hint(auto_sign_on=False) is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Fix B - PUSHED transactions are not collected again (one-shot broadcast)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_pushed_tx_not_recollected():
|
||||
"""A VALID+COMPLETE+PUSHED will must NOT be collected for re-broadcast."""
|
||||
we = {"url": "https://we.example", "selected": True}
|
||||
will = {
|
||||
"tx_done": FakeWillItem(
|
||||
{"VALID", "COMPLETE", "PUSHED"}, dict(we)
|
||||
),
|
||||
}
|
||||
collected = Willexecutors.get_willexecutor_transactions(will)
|
||||
# Nothing to send: the only will is already PUSHED.
|
||||
assert collected == {}
|
||||
|
||||
|
||||
def test_unpushed_tx_is_collected():
|
||||
"""A VALID+COMPLETE but NOT-yet-PUSHED will IS collected for broadcast."""
|
||||
we = {"url": "https://we.example", "selected": True}
|
||||
will = {
|
||||
"tx_new": FakeWillItem({"VALID", "COMPLETE"}, dict(we)),
|
||||
}
|
||||
collected = Willexecutors.get_willexecutor_transactions(will)
|
||||
assert "https://we.example" in collected
|
||||
assert "tx_new" in collected["https://we.example"]["txsids"]
|
||||
|
||||
|
||||
def test_mixed_only_unpushed_collected():
|
||||
"""With one PUSHED and one not-pushed will on different servers, only the
|
||||
not-pushed one is collected (the successful one is never re-sent)."""
|
||||
will = {
|
||||
"tx_done": FakeWillItem(
|
||||
{"VALID", "COMPLETE", "PUSHED"},
|
||||
{"url": "https://ok.example", "selected": True},
|
||||
),
|
||||
"tx_todo": FakeWillItem(
|
||||
{"VALID", "COMPLETE"},
|
||||
{"url": "https://todo.example", "selected": True},
|
||||
),
|
||||
}
|
||||
collected = Willexecutors.get_willexecutor_transactions(will)
|
||||
assert "https://ok.example" not in collected
|
||||
assert "https://todo.example" in collected
|
||||
@@ -33,7 +33,8 @@ def test_color_invalidated():
|
||||
|
||||
|
||||
def test_color_invalidated_overrides_lower():
|
||||
item = FakeWillItem(INVALIDATED=True, PENDING=True, COMPLETE=True)
|
||||
# PENDING was renamed to MEMPOOL (A2).
|
||||
item = FakeWillItem(INVALIDATED=True, MEMPOOL=True, COMPLETE=True)
|
||||
assert status_color(item) == "#f87838"
|
||||
|
||||
|
||||
@@ -41,12 +42,20 @@ def test_color_replaced():
|
||||
assert status_color(FakeWillItem(REPLACED=True)) == "#ff97e9"
|
||||
|
||||
|
||||
def test_color_updated():
|
||||
# UPDATED is a new status (A2): light violet. It is checked after REPLACED
|
||||
# but before CONFIRMED/MEMPOOL in the priority list. The original violet
|
||||
# (#800080) was too dark to read, so it was lightened to #b266b2.
|
||||
assert status_color(FakeWillItem(UPDATED=True)) == "#b266b2"
|
||||
|
||||
|
||||
def test_color_confirmed():
|
||||
assert status_color(FakeWillItem(CONFIRMED=True)) == "#bfbfbf"
|
||||
|
||||
|
||||
def test_color_pending():
|
||||
assert status_color(FakeWillItem(PENDING=True)) == "#ffce30"
|
||||
def test_color_mempool():
|
||||
# PENDING was renamed to MEMPOOL (A2); the colour (yellow) is unchanged.
|
||||
assert status_color(FakeWillItem(MEMPOOL=True)) == "#ffce30"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -118,11 +118,16 @@ def test_locktime_editor_min_max():
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_locktime_raw_edit_replace_str():
|
||||
# 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).
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
assert LockTimeRawEdit.replace_str("123d") == "123"
|
||||
assert LockTimeRawEdit.replace_str("456y") == "456"
|
||||
assert LockTimeRawEdit.replace_str("789b") == "789"
|
||||
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456"
|
||||
# "b" is left untouched (no longer a recognised suffix)
|
||||
assert LockTimeRawEdit.replace_str("789b") == "789b"
|
||||
# only d/y are stripped; a stray "b" remains
|
||||
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b"
|
||||
|
||||
|
||||
def test_locktime_raw_edit_checkbdy():
|
||||
|
||||
Reference in New Issue
Block a user