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:
2026-06-28 23:00:23 -04:00
parent 10d0b85779
commit dc166d04ff
23 changed files with 1374 additions and 205 deletions

View 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")

View 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")

View File

@@ -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

View File

@@ -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
# ------------------------------------------------------------------ #

View 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

View File

@@ -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"
# ------------------------------------------------------------------ #

View File

@@ -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():