feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests

This commit is contained in:
2026-09-14 09:11:50 -04:00
parent 9c4697c923
commit fb88d7540c
82 changed files with 11665 additions and 3116 deletions

18
tests/conftest.py Normal file
View File

@@ -0,0 +1,18 @@
"""Shared pytest fixtures.
Guards every test against cross-file network pollution: several karen7
regtest modules historically flipped ``electrum.constants.net`` to regtest at
import time, which broke unrelated offline tests (e.g. the CLI controller
suite) run in the same pytest process.
"""
import pytest
from electrum import constants
@pytest.fixture(autouse=True)
def _restore_network():
"""Snapshot ``constants.net`` before each test and restore it after."""
prev = constants.net
yield
constants.net = prev

File diff suppressed because it is too large Load Diff

View File

@@ -21,12 +21,12 @@ Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
"""
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import copy_structure
from bal.core.will import (
HeirNotFoundException,
NoHeirsException,
@@ -58,7 +58,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"heirs": copy_structure(heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -67,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
# Force the locktime frozen "inside" the signed tx.
item.tx.locktime = tx_locktime
if status_complete:
@@ -118,7 +118,7 @@ def main():
# Scenario 0: nothing changed -> should be coherent.
heirs = {"alice": ["addr_alice", 5000, same_lt]}
_run("0. nothing changed",
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
will_heirs=heirs, current_heirs=copy_structure(heirs),
tx_locktime=base_lt, check_date=0)
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.

View File

@@ -27,7 +27,6 @@ Run:
tests/test_anticipate_manual_locktime.py -q
"""
import copy
import os
import sys
@@ -35,6 +34,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
from bal.core.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402
NotCompleteWillException,
Will,
@@ -70,7 +70,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"heirs": copy_structure(heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)

View File

@@ -18,6 +18,7 @@ import shutil
import sys
import tempfile
import time
import unittest.mock as mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@@ -25,6 +26,8 @@ from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException
from bal.cli.controller import BalController
from bal.core.heirs import Heirs
from bal.core.util import Util
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
@@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates():
assert result["invalidation_tx"] == {"txid": None, "tx": None}
def test_build_will_reanchors_date_to_check_to_new_locktime():
"""CLI mirror of the GUI regression: ``build_will`` must re-anchor
``date_to_check`` to the CURRENT heirs' earliest delivery before building,
so an anticipated (shortened) rebuild is not blocked by the old built-will
anchor (which would yield NO_FUTURE_DATE in ``get_transactions``).
"""
with Plugin() as plugin:
plugin.USER_TYPE.set("advanced")
plugin.NO_WILLEXECUTOR.set(True)
plugin.ENABLE_MULTIVERSE.set(True)
plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20})
c = _make_controller(plugin)
c.no_willexecutor = True
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]
# Simulate an old built will frozen at 2y: reload keeps its (stale)
# anchor, which would reject the anticipated "1y" delivery.
c.init_class_variables()
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
c.date_to_check = stale_anchor
assert Util.parse_locktime_string("1y") < c.date_to_check
with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt:
result = c.build_will()
assert result == {}
# build_will re-anchored date_to_check to the new 1y delivery...
expected = Util.parse_locktime_string("1y") - 150 * 86400
assert abs(c.date_to_check - expected) < 3600
# ...and used THAT anchor as the build filter, not the stale 2y one.
assert gt.call_args.args[-1] == c.date_to_check
# ------------------------------------------------------------------ #
# runner
# ------------------------------------------------------------------ #

View File

@@ -0,0 +1,482 @@
"""
Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop).
Validates the self-contained codecs against the published spec vectors
(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact
output captured from the reference C++ bc-ur encoder (fountain/xoshiro/
alias-sampler parity), plus round trips, out-of-order assembly, missing-part
fountain solving and malformed-input rejection for all four formats.
Run:
source electrum/env/bin/activate
python3 tests/test_core_animated_qr.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import random
from bal.core import animated_qr as aq
def _payload(plen: int) -> bytes:
"""Deterministic payload matching the C++ reference driver (``(i*7)&0xff``)."""
return bytes((i * 7) & 0xFF for i in range(plen))
# --------------------------------------------------------------------------- #
# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors)
# --------------------------------------------------------------------------- #
def test_bc32_official_vectors():
cases = [
(b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"),
(b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"),
(
bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"),
"my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu",
),
]
for payload, encoded in cases:
assert aq.bc32_encode(payload) == encoded
assert aq.bc32_decode(encoded) == payload
def test_bc32_checksum_rejected():
good = aq.bc32_encode(b"Hello, world")
corrupted = good[:-1] + ("a" if good[-1] != "a" else "b")
try:
aq.bc32_decode(corrupted)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for corrupted BC32")
def test_bc32_bad_char_rejected():
try:
aq.bc32_decode("1" * 26)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)")
# --------------------------------------------------------------------------- #
# Bytewords (BCR-2020-012)
# --------------------------------------------------------------------------- #
def test_bytewords_minimal_roundtrip():
samples = [bytes(range(256)), _payload(59), b"\x00"] + [
os.urandom(64) for _ in range(4)
]
for data in samples:
words = aq.bytewords_minimal_encode(data)
assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC
assert aq.bytewords_minimal_decode(words) == data
def test_bytewords_rejects_corrupted_crc():
data = _payload(40)
words = aq.bytewords_minimal_encode(data)
flip = "a" if words[-1] != "a" else "b"
try:
aq.bytewords_minimal_decode(words[:-1] + flip)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for corrupted CRC")
def test_bytewords_rejects_odd_length():
try:
aq.bytewords_minimal_decode("abc")
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for odd-length bytewords")
# --------------------------------------------------------------------------- #
# BC-UR v2: byte-exact parity with the reference C++ encoder
# --------------------------------------------------------------------------- #
# Reference frames from the bc-ur C++ fountain encoder
# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart).
REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo"
REF_V2_MULTI_59 = [
"ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket",
"ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt",
"ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue",
"ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls",
"ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt",
"ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm",
"ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg",
"ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt",
"ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn",
"ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd",
"ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin",
"ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee",
]
# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data.
REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59)
def test_v2_single_part_matches_reference():
frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12))
assert frames == [REF_V2_SINGLE_12]
def test_v2_reference_frames_decode_and_reencode_exactly():
message = REF_V2_MULTI_59_MSG
fragment_len = -(-len(message) // 2)
for frame in REF_V2_MULTI_59:
seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame)
assert seq_len == 2
assert message_len == len(message)
assert checksum == aq.crc32_int(message)
assert len(data) == fragment_len
# re-encoding the parsed values reproduces the reference line exactly
assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame
# our choose_fragments + partition + xor reproduces the reference data
indexes = aq.choose_fragments(seq, seq_len, checksum)
assert seq_num_indexes_valid(seq, seq_len, indexes)
mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len)
assert mixed == data
def seq_num_indexes_valid(seq, seq_len, indexes):
# pure part for seq <= seq_len contains exactly fragment seq-1
if seq <= seq_len:
return indexes == {seq - 1}
return set(indexes) <= set(range(seq_len)) and bool(indexes)
def test_v2_multipart_encoder_matches_reference_from_seq2():
# Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the
# reference (which starts at seq 2 due to first_seq_num=1).
mine = aq.ur2_frames(_payload(59), 120)
assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1]
assert mine[1:4] == REF_V2_MULTI_59[:3]
def test_v2_reference_seq7_mix_parity():
# Higher-degree mixed parts (seq_len=7) also match: message uses the
# reference head 0x58|0x00 for the 256-byte driver payload.
message = bytes([0x58, 0x00]) + _payload(256)
seq_len = 7
fragment_len = -(-len(message) // seq_len)
frames = [
"ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd",
"ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo",
"ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd",
]
for frame in frames:
seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame)
assert sl == seq_len and mlen == len(message)
assert checksum == aq.crc32_int(message)
mixed = aq._mix_fragments(
aq._partition_message(message, fragment_len),
aq.choose_fragments(seq, seq_len, checksum),
fragment_len,
)
assert mixed == data
# --------------------------------------------------------------------------- #
# BC-UR v2: sessions / fountain decoding
# --------------------------------------------------------------------------- #
def test_v2_roundtrip_in_order():
payload = ("BAL transfer " * 9).encode()
frames = aq.ur2_frames(payload, 120)
seq_len = int(frames[0].split("/")[1].split("-")[1])
assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave
session = aq.AnimatedQrSession()
for frame in frames:
session.add_part(frame)
assert session.done
assert session.received == session.total
text, _ = session.resolve()
assert text == payload.decode()
def test_v2_out_of_order_and_duplicate():
payload = ("BAL transfer " * 9).encode()
frames = aq.ur2_frames(payload, 120)
order = list(range(len(frames)))
random.Random(11).shuffle(order)
session = aq.AnimatedQrSession()
for i in order:
status = session.add_part(frames[i])
assert status in ("ok", "dup")
session.add_part(frames[0]) # duplicate of an already-received part
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v2_solves_without_a_pure_fragment():
payload = ("BAL transfer " * 9).encode()
frames = aq.ur2_frames(payload, 120)
session = aq.AnimatedQrSession()
for frame in frames[1:]: # drop the first pure fragment
session.add_part(frame)
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v2_single_part_import():
session = aq.AnimatedQrSession()
session.add_part(REF_V2_SINGLE_12)
assert session.done and session.total == 1
assert session.resolve()[0] == _payload(12).decode("latin-1")
def test_v2_conflicting_transfer_rejected():
payload_a = b"AAAAAAAAAAAAAAAA"
payload_b = b"BBBBBBBBBBBBBBBB"
fa = aq.ur2_frames(payload_a, 500)[0]
fb = aq.ur2_frames(payload_b, 500)[0]
session = aq.AnimatedQrSession()
session.add_part(fa)
try:
session.add_part(fb)
except aq.TransferConflictError:
pass
else:
raise AssertionError("expected TransferConflictError for a different transfer")
def test_v2_corrupt_crc_rejected():
frame = list(REF_V2_MULTI_59[0])
idx = len(frame) - 1
frame[idx] = "a" if frame[idx] != "a" else "b"
try:
aq.ur2_parse_part("".join(frame))
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for a corrupt v2 part")
def test_v2_session_cap_rejected():
part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100)
session = aq._Ur2Session()
try:
session.add(part)
except aq.SessionLimitError:
pass
else:
raise AssertionError("expected SessionLimitError for oversized seq_len")
# --------------------------------------------------------------------------- #
# BC-UR v1
# --------------------------------------------------------------------------- #
def test_v1_multipart_roundtrip():
payload = ("v1 transfer payload " * 6).encode()
frames = aq.ur1_frames(payload, 120)
assert len(frames) > 1
session = aq.AnimatedQrSession()
for frame in reversed(frames):
session.add_part(frame)
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v1_single_part_roundtrip():
payload = b"hello, bal"
frames = aq.ur1_frames(payload, 400)
assert len(frames) == 1
session = aq.AnimatedQrSession()
session.add_part(frames[0])
assert session.done and session.total == 1
assert session.resolve()[0] == payload.decode()
def test_v1_headerless_single_part_import():
# bcr-2020-005 rev1 allows omitting the sequence header + digest entirely.
payload = b"hello, bal"
message = aq.cbor_byte_string(payload)
single = "ur:bytes/" + aq.bc32_encode(message)
assert aq.detect_format(single) == "ur1"
session = aq.AnimatedQrSession()
session.add_part(single)
assert session.done
assert session.resolve()[0] == payload.decode()
def test_v1_digest_mismatch_rejected():
frame = aq.ur1_frames(b"hello, bal", 400)[0]
tampered = frame[:-4] + "abcd"
session = aq.AnimatedQrSession()
session.add_part(tampered)
try:
session.resolve()
except aq.ChecksumError:
pass
else:
raise AssertionError("expected ChecksumError for a tampered v1 digest")
def test_v1_part_numbers_validated():
for bad in (
"ur:bytes/0of1/{}full".format("x" * 51),
"ur:bytes/2of1/{}full".format("x" * 51),
"ur:bytes/1of0/{}full".format("x" * 51),
"ur:bytes/1aof1/{}full".format("x" * 51),
):
try:
aq.ur1_parse_part(bad)
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for: {}".format(bad))
# --------------------------------------------------------------------------- #
# BBQR
# --------------------------------------------------------------------------- #
def test_bbqr_all_encodings_roundtrip():
payload = ("BBQR payload " * 8).encode()
for encoding in ("Z", "2", "H"):
frames = aq.bbqr_frames(payload, 90, encoding=encoding)
assert len(frames) >= 1
order = list(range(len(frames)))
random.Random(3).shuffle(order)
session = aq.AnimatedQrSession()
for i in order:
session.add_part(frames[i])
assert session.done
assert session.resolve()[0] == payload.decode()
def test_bbqr_compression_default_and_fallback():
payload = ("repetitive data " * 40).encode() # compresses well
frames_z = aq.bbqr_frames(payload, 90, encoding="Z")
# Highly compressible: Z yields one frame and a 'Z' flag.
assert all(f[2] == "Z" for f in frames_z)
assert len(frames_z) == 1
raw = os.urandom(600) # incompressible
frames_2 = aq.bbqr_frames(raw, 90, encoding="Z")
assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used
def test_bbqr_hex_uppercase():
payload = b"\xde\xad\xbe\xef"
frame = aq.bbqr_frames(payload, 50, encoding="H")[0]
assert "DEADBEEF" in frame
encoding, _type, total, index, frag = aq.bbqr_parse_part(frame)
assert (encoding, total, index) == ("H", 1, 0)
def test_bbqr_runt_last_part():
payload = os.urandom(33)
frames = aq.bbqr_frames(payload, 60, encoding="2")
parts = [aq.bbqr_parse_part(f)[4] for f in frames]
joined = aq._bbqr_decode(parts, "2")
assert joined == payload
assert len(parts[-1]) < len(parts[0]) # last part is a runt
def test_bbqr_zlib_bomb_rejected():
compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1]
try:
aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data
except aq.AnimatedQrError:
pass
# direct inflate bomb guard:
inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z")
assert inflated[0] == "Z" # 1MB zeros compresses
bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1]
parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)]
try:
aq._bbqr_decode(parts, "Z")
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for an oversized decompression")
def test_bbqr_part_number_limits():
try:
aq.bbqr_frames(os.urandom(30000), 40, encoding="2")
except aq.AnimatedQrError:
pass
else:
raise AssertionError("expected AnimatedQrError for too many BBQR parts")
# --------------------------------------------------------------------------- #
# Detection / parse_for_detection
# --------------------------------------------------------------------------- #
def test_detect_format_recognises_all_formats():
assert aq.detect_format("BALQR1|1|1||payload") == "balqr"
assert aq.detect_format("BAL1" + "001" + "001" + "0" + "payload") == "balqr"
assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1"
assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2"
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
assert aq.detect_format(REF_V2_SINGLE_12) == "ur2"
assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1"
def test_detect_format_rejects_garbage():
for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"):
assert aq.detect_format(text) is None, text
# Lenient prefix probe: a string that merely *starts* with "balqr" is
# reported as balqr (the strict parse then rejects it downstream).
assert aq.detect_format("BALQRX|1|1||a") == "balqr"
def test_parse_for_detection_keys():
bal = aq.parse_for_detection("BALQR1|3|2||payload")
assert bal == ("balqr", "balqr:3", 3, 2)
# Compact v2 frame (fixed 11-char header) is detected too.
bal_v2 = aq.parse_for_detection("BAL1" + "007" + "004" + "0" + "payload")
assert bal_v2 == ("balqr", "balqr:7", 7, 4)
v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0])
assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1
v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0])
assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2]
bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0])
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
def test_format_names_exist():
for fmt in ("balqr", "ur1", "ur2", "bbqr"):
assert aq.format_name(fmt)
assert aq.format_name("nope") == "nope"
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
import traceback
failures = 0
for _name, fn in sorted(globals().items()):
if _name.startswith("test_") and callable(fn):
try:
fn()
print("ok: {}".format(_name))
except Exception:
failures += 1
print("FAIL: {}".format(_name))
traceback.print_exc()
if failures:
print("{} test(s) failed".format(failures))
sys.exit(1)
print("all tests passed")

View File

@@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
resolve_guard_threshold,
)
# ------------------------------------------------------------------ #
@@ -148,6 +149,76 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
assert abs(result - expected) < 1
# ------------------------------------------------------------------ #
# resolve_guard_threshold
# ------------------------------------------------------------------ #
def test_guard_threshold_basic_mode_returns_none():
fake_now = 1_800_000_000.0
threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now)
assert threshold is None
def _guard_locktime(settings, fake_now):
"""Reproduce the call-site locktime expression of the guard."""
from bal.core.plugin_base import BalTimestamp
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
def test_guard_threshold_absolute():
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
threshold = locktime - 30 * 86400
settings = {"locktime": locktime, "threshold": threshold}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
def test_guard_threshold_relative_fresh_anchor():
"""A relative threshold must be anchored to the FRESH locktime so the
guard and the settings always share one reference frame.
Regression for the false positive where a still-valid built will frozen at
a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently
stored shorter delivery ("1y"): the old guard compared the fresh "1y"
locktime against that anchored threshold and wrongly fired "locktime is
lower than threshold", even though the settings themselves are consistent
(locktime is 30d AFTER the threshold).
"""
fake_now = 1_800_000_000.0
settings = {"locktime": "1y", "threshold": "30d"}
locktime = _guard_locktime(settings, fake_now)
threshold = resolve_guard_threshold(False, settings, now=fake_now)
assert threshold is not None
assert locktime > threshold # internally consistent: no fire
assert threshold > fake_now
# The helper takes no built anchor: a frozen "2y" built will must NOT
# contaminate the result, although resolve_date_to_check (the expiry
# reference) legitimately keeps using it.
frozen_two_years = locktime + 365 * 86400
anchored = resolve_date_to_check(
False, settings, now=fake_now, built_locktime=frozen_two_years
)
assert anchored > threshold # built anchor pushes date_to_check forward...
assert locktime < anchored # ...which is exactly what used to fire the bug
def test_guard_threshold_relative_locktime_absolute_threshold():
fake_now = 1_800_000_000.0
threshold = fake_now + 200 * 86400
settings = {"locktime": "1y", "threshold": threshold}
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
# "1y" from now is later than the stored absolute threshold: allowed.
locktime = _guard_locktime(settings, fake_now)
assert locktime > threshold
def test_guard_threshold_missing_returns_none():
fake_now = 1_800_000_000.0
assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None
# ------------------------------------------------------------------ #
# check_alive_expired
# ------------------------------------------------------------------ #

View File

@@ -36,6 +36,7 @@ from bal.core.heirs import (
is_op_return_address,
validate_op_return_hex,
)
from bal.core.util import Util
# ------------------------------------------------------------------ #
# Constants
@@ -167,6 +168,32 @@ def test_heirs_amount_to_float():
assert heirs.amount_to_float("notanumber") == 0.0
def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs():
"""A relative heir must survive the amount filter when the build anchor
(``from_locktime``) is recalculated for the anticipated delivery.
Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD
(longer) built will; an "1y" heir resolved before that anchor was excluded
by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the
anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the
"1y" heir is kept.
"""
wallet = FakeWallet()
heirs = Heirs(wallet)
heirs["carol"] = ["addr1", "100%", "1y"]
# Stale anchor (old built 2y will still frozen): "1y" is in the past
# relative to it -> excluded from the amount calculation.
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500)
assert "carol" not in percent_heirs
# Recalculated anchor for the new (1y) delivery: the heir is retained.
new_anchor = Util.parse_locktime_string("1y") - 150 * 86400
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500)
assert "carol" in percent_heirs
# ------------------------------------------------------------------ #
# Validation (static methods)
# ------------------------------------------------------------------ #

View File

@@ -14,7 +14,7 @@ import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
@@ -74,7 +74,7 @@ def test_bt_to_date_absolute():
def test_bt_to_date_relative():
now = datetime.now()
now = datetime.now(timezone.utc)
# relative days from now
bt = BalTimestamp("7d")
@@ -86,8 +86,8 @@ def test_bt_to_date_relative():
d_rev = bt.to_date(reverse=True)
assert d_rev < now
# from explicit datetime
base = datetime(2025, 6, 1, 12, 0, 0)
# from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable)
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
d = bt.to_date(from_date=base)
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
assert d == expected
@@ -101,7 +101,7 @@ def test_bt_to_date_relative():
def test_bt_to_date_years():
bt = BalTimestamp("1y")
d = bt.to_date()
assert d > datetime.now()
assert d > datetime.now(timezone.utc)
def test_bt_to_date_overflow():

View File

@@ -0,0 +1,439 @@
"""
Tests for ``bal.core.qrtransfer``.
Covers the BALQR frame encoding used for will transfer via QR codes /
audio modem: encoding, framing, reassembly, malformed input and the preset
list (optionally cross-checked against the ``qrcode`` library's EC-M
capacity when it is installed).
Run:
source electrum/env/bin/activate
python3 tests/test_core_qr_transfer.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.qrtransfer import (
CHUNK_PRESETS,
MIN_CHUNK_SIZE,
InconsistentTotalError,
MissingFramesError,
QrTransferError,
assemble,
decode_transfer,
encode_transfer,
parse_frame,
preset_index_for_chunk_size,
split_frames,
)
def _frames(tx_strings, chunk_size, compress=False):
"""Split a payload and return (payload, total, {index: payload})."""
payload = encode_transfer(tx_strings, compress=compress)
parsed = {}
total = None
for frame in split_frames(payload, chunk_size, compressed=compress):
t, index, _compressed, p = parse_frame(frame)
if total is not None:
assert total == t
total = t
parsed[index] = p
assert total is not None
return payload, total, parsed
# --------------------------------------------------------------------------- #
# Round trips
# --------------------------------------------------------------------------- #
def test_encode_decode_plain():
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
payload = encode_transfer(tx_strings, compress=False)
assert decode_transfer(payload, compressed=False) == tx_strings
def test_encode_decode_compressed():
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
payload = encode_transfer(tx_strings, compress=True)
assert decode_transfer(payload, compressed=True) == tx_strings
def test_empty_list_roundtrip():
assert decode_transfer(encode_transfer([]), compressed=False) == []
# --------------------------------------------------------------------------- #
# Framing
# --------------------------------------------------------------------------- #
def test_single_frame():
tx_strings = ["11" * 10]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) == 1
total, index, compressed, p = parse_frame(frames[0])
assert (total, index, compressed) == (1, 1, False)
assert p == payload
def test_multiple_frames_reassemble():
tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames
_payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1])
assert total > 1
decoded = decode_transfer(assemble(parsed, total), compressed=False)
assert decoded == tx_strings
def test_size_greater_than_payload():
tx_strings = ["12" * 5]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 1800)
assert len(frames) == 1
_t, _i, _c, p = parse_frame(frames[0])
assert p == payload
def test_exact_single_frame_boundary():
# A 139-byte payload exactly fills the 150-byte preset budget (the 11-char
# compact header plus payload), so the encoded frame is exactly 150.
tx_strings = ["a" * 139]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) == 1
assert len(frames[0]) == 150
_t, _i, _c, p = parse_frame(frames[0])
assert p == payload
def test_frames_fit_chunk_size():
tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)]
payload = encode_transfer(tx_strings)
for _label, size in CHUNK_PRESETS:
for frame in split_frames(payload, size):
assert len(frame) <= size, (size, len(frame))
def test_single_tx_larger_than_chunk():
# A huge serialized tx must be split over several frames and reassemble
# exactly (positional slicing is safe for hex/base64 text).
tx_strings = ["7b" * 1000] # 2000 chars
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
assert len(frames) > 1
parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames}
total = parse_frame(frames[0])[0]
assert assemble(parsed, total) == payload
def test_compressed_frames_carry_flag():
tx_strings = ["ab" * 40]
frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True)
for frame in frames:
_t, _i, compressed, _p = parse_frame(frame)
assert compressed is True
# Plain frames do not.
frames_plain = split_frames(encode_transfer(tx_strings), 150)
_t, _i, compressed, _p = parse_frame(frames_plain[0])
assert compressed is False
def test_compressed_roundtrip_through_frames():
tx_strings = ["ab" * 50, "cd" * 50, "12" * 60]
payload = encode_transfer(tx_strings, compress=True)
parsed = {}
total = None
for frame in split_frames(payload, 400, compressed=True):
t, index, _c, p = parse_frame(frame)
total = t
parsed[index] = p
assert total is not None
decoded = decode_transfer(assemble(parsed, total), compressed=True)
assert decoded == tx_strings
# --------------------------------------------------------------------------- #
# Compact v2 wire format ("BAL1")
# --------------------------------------------------------------------------- #
def test_v2_frame_header_structure():
frames = split_frames(encode_transfer(["11" * 10]), 150)
assert len(frames) == 1
frame = frames[0]
assert frame.startswith("BAL1")
# Fixed 11-char header: magic + 3-char total + 3-char index + 1 flag.
assert len(frame) > 11
magic, total_s, index_s, flag, payload = (
frame[:4],
frame[4:7],
frame[7:10],
frame[10],
frame[11:],
)
assert magic == "BAL1"
assert total_s == "001"
assert index_s == "001"
assert flag == "0"
assert payload == "11" * 10
total, index, compressed, p = parse_frame(frame)
assert (total, index, compressed) == (1, 1, False)
assert p == payload
def test_v2_compressed_flag_is_z():
frames = split_frames(
encode_transfer(["11" * 10], compress=True), 150, compressed=True
)
assert frames[0][10] == "Z"
_t, _i, compressed, _p = parse_frame(frames[0])
assert compressed is True
def test_v2_header_fixed_width_high_counts():
# A long transfer needs multi-digit counts; the v2 header stays exactly
# 11 chars no matter how many frames (3-char base36 zero-padded counts).
tx_strings = ["ab" * 300] # 600 chars -> several frames at 150
frames = split_frames(encode_transfer(tx_strings), 150)
assert len(frames) > 1
for frame in frames:
# magic(4) + total(3) + index(3) + flag(1) = 11 chars, then payload.
assert len(frame) - len(frame[11:]) == 11
def test_v2_max_frame_count():
# A transfer needing more than 46655 frames must be rejected (3-char
# base36 count fields cannot represent larger totals).
from bal.core.qrtransfer import _MAX_TOTAL
oversized = "A" * (_MAX_TOTAL * (150 - 11) + 1)
try:
split_frames(oversized, 150)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError above the frame cap")
def test_v2_boundary_at_max_count():
from bal.core.qrtransfer import _MAX_TOTAL
# Exactly at the cap: must still produce (bounded) frames with 3-char
# counts "VVV" (46655) for the highest serialised part.
payload = "B" * (_MAX_TOTAL * (150 - 11))
frames = split_frames(payload, 150)
assert len(frames) == _MAX_TOTAL
total, index, _c, _p = parse_frame(frames[-1])
assert total == _MAX_TOTAL
assert index == _MAX_TOTAL
assert frames[-1][:10] == "BAL1" + "ZZZ" + "ZZZ"
def test_encode_transfer_best():
from bal.core.qrtransfer import encode_transfer_best
# Redundant JSON-ish text compresses -> compressed (and longer source
# must round-trip unchanged).
txs = ['{"a": "%s"}' % ("x" * 300), '{"b": "%s"}' % ("y" * 300)]
transfer, compressed = encode_transfer_best(txs)
assert compressed is True
assert decode_transfer(transfer, compressed) == txs
# Already-compact input stays plain (never larger than the source).
txs_small = ["ab", "cd"]
transfer, compressed = encode_transfer_best(txs_small)
assert compressed is False
assert decode_transfer(transfer, compressed) == txs_small
def test_v2_malformed_frames():
bad = (
"BAL1", # header only, no fields
"BAL1" + "001", # truncated
"BAL1" + "G-1" + "001" + "Z" + "p", # non-base36 total
"BAL1" + "001" + "G-1" + "Z" + "p", # non-base36 index
"BAL1" + "000" + "001" + "Z" + "p", # total 0
"BAL1" + "001" + "000" + "Z" + "p", # index 0
"BAL1" + "001" + "002" + "Z" + "p", # index beyond total
"BAL1" + "001" + "001" + "Q" + "p", # unknown flag
)
for frame in bad:
try:
parse_frame(frame)
except QrTransferError:
continue
raise AssertionError("expected QrTransferError for: {!r}".format(frame))
# --------------------------------------------------------------------------- #
# Malformed input
# --------------------------------------------------------------------------- #
def test_parse_bad_magic_and_version():
for frame in (
"BALQR|1|1||a", # missing version
"BALQR2|1|1||a", # unknown version
"XXXXX1|1|1||a", # unknown magic
):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_bad_arity():
for frame in ("BALQR1", "BALQR1|1|1|"):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_pipe_in_payload_is_folded():
# maxsplit keeps the tail (including any inner '|') in the payload part.
frame = "BALQR1|1|1||a|b|c"
total, index, compressed, payload = parse_frame(frame)
assert (total, index, compressed) == (1, 1, False)
assert payload == "a|b|c"
def test_parse_bad_numbers():
for frame in (
"BALQR1|x|1||a",
"BALQR1|1|y||a",
"BALQR1|0|1||a",
"BALQR1|1|0||a",
"BALQR1|1|2||a", # index beyond total
"BALQR1|-1|1||a",
):
try:
parse_frame(frame)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for: {}".format(frame))
def test_parse_bad_flags():
try:
parse_frame("BALQR1|1|1|Q|payload")
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for unknown flags")
def test_assemble_missing_frames():
try:
assemble({1: "a", 3: "c"}, total=3)
except MissingFramesError as e:
assert e.missing == [2]
else:
raise AssertionError("expected MissingFramesError")
def test_assemble_index_beyond_total():
try:
assemble({1: "a", 2: "b"}, total=1)
except InconsistentTotalError:
pass
else:
raise AssertionError("expected InconsistentTotalError")
def test_assemble_order_and_total_validation():
assert assemble({1: "a", 2: "b"}, total=2) == "ab"
try:
assemble({}, total=0)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError")
# --------------------------------------------------------------------------- #
# Constants / presets
# --------------------------------------------------------------------------- #
def test_preset_count_and_order():
assert len(CHUNK_PRESETS) == 4
budgets = [budget for _label, budget in CHUNK_PRESETS]
assert budgets == sorted(budgets)
def test_preset_index_for_chunk_size():
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
assert preset_index_for_chunk_size(budget) == index
assert preset_index_for_chunk_size(150) == 0
assert preset_index_for_chunk_size(1800) == 3
def test_min_chunk_size_guard():
try:
split_frames("x" * 10, MIN_CHUNK_SIZE - 1)
except QrTransferError:
pass
else:
raise AssertionError("expected QrTransferError for tiny chunk size")
def test_split_frame_headers_consistent():
tx_strings = ["ab" * 80]
payload = encode_transfer(tx_strings)
frames = split_frames(payload, 150)
totals = {parse_frame(frame)[0] for frame in frames}
assert len(totals) == 1
assert totals.pop() == len(frames)
# --------------------------------------------------------------------------- #
# Optional: cross-check presets against the qrcode library (EC level M)
# --------------------------------------------------------------------------- #
def test_presets_fit_qrcode_ec_m():
"""Every preset budget must render inside a QR at EC level M."""
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_M
except ImportError:
print("qrcode not installed - skipping capacity check")
return
for _label, size in CHUNK_PRESETS:
# Worst-case frame: header with the largest plausible total/index plus
# a full payload of the preset budget.
frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14)
qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)
qr.add_data(frame)
qr.get_matrix() # raises DataOverflowError if it does not fit
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
import traceback
failures = 0
for _name, fn in sorted(globals().items()):
if _name.startswith("test_") and callable(fn):
try:
fn()
print("ok: {}".format(_name))
except Exception:
failures += 1
print("FAIL: {}".format(_name))
traceback.print_exc()
if failures:
print("{} test(s) failed".format(failures))
sys.exit(1)
print("all tests passed")

View File

@@ -8,13 +8,19 @@ Run:
python3 tests/test_core_will.py
"""
import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will, WillItem
from bal.core.checkalive import resolve_date_to_check
from bal.core.util import copy_structure
from bal.core.will import (
HeirNotFoundException,
NoHeirsException,
Will,
WillItem,
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
@@ -48,7 +54,7 @@ def _make_willitem_blank():
"""Create a fresh WillItem from scratch."""
item = WillItem(_make_minimal_willitem_dict())
# Reset STATUS to clean defaults
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
return item
@@ -151,8 +157,8 @@ def test_will_only_valid_list():
def _make_will_with_heirs(heirs, tx_locktime):
"""Build a single-item will whose stored heirs == ``heirs`` and whose
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs)))
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
return {"willid_1": item}
@@ -163,7 +169,7 @@ def test_check_heirs_unchanged_is_coherent():
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(heirs, lt)
result = Will.check_willexecutors_and_heirs(
will, copy.deepcopy(heirs), {}, False, 0, 100
will, copy_structure(heirs), {}, False, 0, 100
)
assert result is True
@@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
assert raised, "adding an heir must raise HeirNotFoundException"
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
ADVANCED check window is anchored to the frozen built delivery must trigger
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
Earlier the count gate resolved each current relative recipe from *now*
while ``check_date`` was anchored to the (longer) frozen built locktime, so
every heir fell below the window and was silently excluded -> NoHeirs even
though the will simply needs rebuilding on the new, shorter schedule."""
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
will = _make_will_with_heirs(will_heirs, lt)
will["willid_1"].set_status("COMPLETE", True)
check_date = resolve_date_to_check(
False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt
)
assert check_date < lt # the anchored window really precedes the delivery
raised = None
try:
Will.check_willexecutors_and_heirs(
will, copy_structure(current_heirs), {}, False, check_date, 100
)
except HeirNotFoundException:
raised = "rebuild"
except NoHeirsException:
raised = "noheirs"
assert raised == "rebuild", (
f"shortened recipe on a signed will must rebuild, got {raised!r}"
)
def test_all_heirs_past_check_date_still_noheirs():
"""The "no valid heirs" gate is preserved: when every heir is coherent with
the built will but its delivery lies before ``check_date``, the check still
reports NoHeirsException (there is literally nothing future to inherit)."""
lt = 1_900_000_000
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(will_heirs, lt)
raised = None
try:
Will.check_willexecutors_and_heirs(
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
)
except HeirNotFoundException:
raised = "rebuild"
except NoHeirsException:
raised = "noheirs"
assert raised == "noheirs", (
f"a fully delivered will must report NoHeirs, got {raised!r}"
)
def test_needs_server_check():
"""Check button selection logic: only a VALID, PUSHED will with a
will-executor that is not yet CHECKED must be queried on the server.

View File

@@ -450,6 +450,12 @@ class FakeADB:
def remove_transaction(self, txid):
self.removed.append(txid)
# Simulate the real adb: dropping a stored tx frees the outputs it spent.
for utxos in self.outputs.values():
for utxo in utxos.values():
if getattr(utxo, "spent_txid", None) == txid:
utxo.spent_txid = None
utxo.spent_height = None
def get_spender(self, outpoint):
txid = self.spenders.get(outpoint)
@@ -837,6 +843,73 @@ def test_get_available_utxos_none_locktime_is_raw_view():
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
# ------------------------------------------------------------------ #
# Will.remove_stale_wallet_history (pre-build history purge)
# ------------------------------------------------------------------ #
def test_remove_stale_wallet_history_frees_equal_locktime_spend():
# The stale placeholders (saved by a previous prepare) have the SAME
# locktime as the will being rebuilt, so get_available_utxos does NOT
# restore their coins (see test_...does_not_restore_not_later_locktime).
# The pre-build purge deletes them and the coins become available again.
wallet, utxo = _wallet_with_local_spend(locktime=1000)
spender = "ab" * 32
assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == []
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
assert removed == [spender]
assert wallet.adb.removed == [spender]
assert spender not in wallet.labels
assert [
u.prevout.to_str()
for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
] == [utxo.prevout.to_str()]
def test_remove_stale_wallet_history_keeps_confirmed_spender():
# A broadcast (confirmed) BAL-labelled tx is never purged.
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=100)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
heights={spender: 100},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
assert removed == []
assert wallet.adb.removed == []
assert wallet.labels[spender] == _HISTORY_LABEL
def test_remove_stale_wallet_history_keeps_unlabeled_local_spender():
# Wallet-local BAL-status tx without a matching history label stays.
wallet, _ = _wallet_with_local_spend(locktime=1000)
spender = "ab" * 32
wallet.labels[spender] = "some other label"
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
assert removed == []
assert wallet.adb.removed == []
assert wallet.labels[spender] == "some other label"
def test_remove_stale_wallet_history_noop_without_wallet_or_adb():
assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == []
wallet = FakeWallet()
wallet.adb = None
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
def test_remove_stale_wallet_history_never_raises():
adb = MagicMock()
adb.get_tx_height.side_effect = RuntimeError("boom")
wallet = MagicMock()
wallet.adb = adb
wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL}
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -17,7 +17,6 @@ Run:
python3 -m pytest tests/test_core_will_invalidate.py -q
"""
import copy
import os
import sys
from unittest.mock import MagicMock, patch
@@ -75,7 +74,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
"change": "",
"baltx_fees": 100,
})
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
# Set the input value so the balance calculation works.
# Use the name-mangled attribute because tx_from_any creates a
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
@@ -270,7 +269,7 @@ class TestInvalidateWill:
"""
item = _make_willitem(value_sats=100)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
wallet = _mock_wallet([_make_utxo(value_sats=100)])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)

View File

@@ -21,7 +21,6 @@ Run:
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
"""
import copy
import json
import os
import sys
@@ -46,6 +45,7 @@ from electrum.transaction import (
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.util import copy_structure
from bal.core.will import Will, WillItem
# ------------------------------------------------------------------ #
@@ -219,7 +219,7 @@ def _txs_to_will(txs, heirs_data):
for txid, tx in txs.items():
item_dict = {
"tx": tx,
"heirs": copy.deepcopy(heirs_data),
"heirs": copy_structure(heirs_data),
"willexecutor": None,
"status": "",
"description": "",

View File

@@ -22,7 +22,6 @@ Run:
python3 -m pytest tests/test_group_e_mock_karen7.py -q
"""
import copy
import json
import os
import sys
@@ -43,6 +42,7 @@ from bal.core.reminders import (
ical_escape,
write_temp_ics,
)
from bal.core.util import copy_structure
from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors
@@ -136,7 +136,7 @@ def _make_willitem(**overrides):
}
d.update(overrides)
item = WillItem(d)
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
return item
@@ -343,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild():
item = WillItem(
{
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(will_heirs),
"heirs": copy_structure(will_heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -352,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild():
"baltx_fees": 100,
}
)
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = lt
will = {"willid_1": item}

View File

@@ -0,0 +1,456 @@
"""
Tests for the filter-based unified export/import dialogs and the BalWindow
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
Covers the shared export filters (All / Valid / Valid NC), the unified
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
receive flow) and the comma-separated tx-only file writer. The audio pages
run against a stub plugin so no sound hardware is exercised.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
"""
import base64
import json
import sys
import zlib
from unittest.mock import patch
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QMainWindow
import bal.gui.qt.dialogs as dialogs
from bal.core.qrtransfer import CHUNK_PRESETS
_app = QApplication.instance() or QApplication(sys.argv)
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2); used for real-WillItem serialization tests.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class FakePlugin:
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
def read_file(self, path):
return _PNG_BYTES
class FakeWindow(QMainWindow):
config = {}
def format_amount(self, amount):
return "{:.8f}".format(amount)
def format_amount_and_units(self, amount):
return "{:.8f} sat".format(amount)
class FakeAudioPlugin:
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
def __init__(self):
self.modem_config = None
def is_available(self):
return True
class _FakeModemConfig:
def __init__(self, kbps):
self.modem_bps = kbps * 1000
class FakeBalWindow:
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
def __init__(self, audio_plugin=None):
self.window = FakeWindow()
self.bal_plugin = FakePlugin()
self.willitems = {}
self.audio_plugin = audio_plugin
self.bitrate_set = None
self.audio_payloads = []
def get_audio_modem_plugin(self):
return self.audio_plugin
def set_audio_modem_bitrate(self, kbps):
self.bitrate_set = kbps
if self.audio_plugin is not None:
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
def _audio_send_payload(self, payload):
self.audio_payloads.append(payload)
def export_json_file(self, path, will=None):
items = will if will is not None else self.willitems
with open(path, "w", encoding="utf-8") as f:
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
def export_tx_file(self, path, will=None):
items = will if will is not None else self.willitems
with open(path, "w", encoding="utf-8") as f:
f.write(",".join(str(wi.tx) for _, wi in items.items()))
class StubTx:
def __init__(self, payload):
self.payload = payload
def txid(self):
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
def __str__(self):
return self.payload
class StubWillItem:
def __init__(self, payload, statuses=None):
self.tx = StubTx(payload)
self.statuses = statuses or {}
def get_status(self, name):
return self.statuses.get(name, False)
def to_dict(self):
return {"tx": str(self.tx)}
def _make_willitems(n=3, payload_len=60, statuses=None):
return {
"item{}".format(i): StubWillItem(
"T{}".format(i) * payload_len, statuses=statuses
)
for i in range(n)
}
# ------------------------------------------------------------------ #
# Shared export filters
# ------------------------------------------------------------------ #
def test_export_filter_options():
opts = dialogs.export_filter_options()
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
valid = StubWillItem("V*", statuses={"VALID": True})
plain = StubWillItem("P*")
assert opts[0][1](complete) and opts[0][1](plain)
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
assert not opts[2][1](complete)
assert opts[2][1](valid) and not opts[2][1](plain)
def test_filter_willitems_by_index():
items = {
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
"b": StubWillItem("B*", statuses={"VALID": True}),
"c": StubWillItem("C*"),
}
opts = dialogs.export_filter_options()
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
# ------------------------------------------------------------------ #
# WillExportDialog file page
# ------------------------------------------------------------------ #
def test_file_export_selects_by_filter():
bw = FakeBalWindow()
items = _make_willitems(3)
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
items["item1"].statuses = {"VALID": True}
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
assert set(d._selected_items()) == set(items)
d._on_filter_change(1)
assert set(d._selected_items()) == {"item0", "item1"}
d._on_filter_change(2)
assert list(d._selected_items()) == ["item1"]
d.close()
def test_file_export_run_tx_only(tmpdir):
bw = FakeBalWindow()
items = _make_willitems(3)
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
d.content_check.setChecked(False)
captured = {}
def fake_gui(window, title, exporter):
captured["title"] = title
captured["exporter"] = exporter
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
d._export_file()
assert captured["title"] == "will_tx"
out = tmpdir.join("will_tx.txt").strpath
captured["exporter"](out)
expected = ",".join(str(wi.tx) for _, wi in items.items())
assert open(out, encoding="utf-8").read() == expected
d.close()
def test_file_export_run_willitem(tmpdir):
bw = FakeBalWindow()
items = _make_willitems(2)
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
assert d.content_check.isChecked()
captured = {}
def fake_gui(window, title, exporter):
captured["title"] = title
captured["exporter"] = exporter
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
d._export_file()
assert captured["title"] == "will"
out = tmpdir.join("will.json").strpath
captured["exporter"](out)
data = json.load(open(out, encoding="utf-8"))
assert set(data) == set(items)
assert data["item0"]["tx"] == str(items["item0"].tx)
d.close()
def test_file_export_empty_under_filter_aborts():
bw = FakeBalWindow()
items = {
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
}
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
messages = []
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
d._filter_index = 2 # force an empty "Valid NC" selection
with patch.object(dialogs, "export_meta_gui") as gui:
d._export_file()
assert not gui.called
assert messages
d.close()
# ------------------------------------------------------------------ #
# BalWindow transport helpers
# ------------------------------------------------------------------ #
def test_bal_window_export_tx_file(tmpdir):
import bal.gui.qt.window as window
items = _make_willitems(3)
bw = object.__new__(window.BalWindow)
bw.willitems = items
out = tmpdir.join("will_tx.txt").strpath
bw.export_tx_file(out)
expected = ",".join(str(wi.tx) for _, wi in items.items())
assert open(out, encoding="utf-8").read() == expected
def test_bal_window_set_audio_modem_bitrate():
try:
import amodem.config
except ImportError:
return
import bal.gui.qt.window as window
class P:
def __init__(self):
self.modem_config = None
probe = P()
bw = object.__new__(window.BalWindow)
bw.get_audio_modem_plugin = lambda: probe
bw.set_audio_modem_bitrate(1)
assert probe.modem_config is amodem.config.bitrates[1]
# ------------------------------------------------------------------ #
# WillExportDialog audio page
# ------------------------------------------------------------------ #
def test_audio_export_page_send():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
items = _make_willitems(3, payload_len=30)
bw.willitems = items
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.audio_send_btn.text() == dialogs._("Send")
assert d.audio_send_btn.isEnabled()
assert d.kbps_combo.count() > 0
kbps = int(d.kbps_combo.currentText())
d._send_audio()
assert bw.bitrate_set == kbps
# Whole-will default: the audio payload is a single JSON document.
assert len(bw.audio_payloads) == 1
data = json.loads(bw.audio_payloads[0])
assert set(data) == set(items)
d.close()
def test_audio_export_page_send_tx_only():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
items = _make_willitems(3, payload_len=30)
bw.willitems = items
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
d.content_check.setChecked(False)
kbps = int(d.kbps_combo.currentText())
d._send_audio()
assert bw.bitrate_set == kbps
expected = "\n".join(dialogs.serialize_tx_list(items))
assert bw.audio_payloads == [expected]
d.close()
def test_audio_export_page_plugin_missing():
# The window stays usable: only the audio option is disabled.
bw = FakeBalWindow(audio_plugin=None)
bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.audio_send_btn.isEnabled()
assert "not available" in d.audio_warn_label.text()
assert len(d.qr_page.frames) >= 1 # QR still usable
assert d.file_export_btn.isEnabled()
d.close()
# ------------------------------------------------------------------ #
# WillImportDialog audio page
# ------------------------------------------------------------------ #
def test_audio_import_page_build():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.kbps_combo.count() > 0
assert d.receive_btn.text() == dialogs._("Receive by audio…")
assert d.receive_btn.isEnabled()
d.close()
def test_audio_import_page_plugin_missing():
bw = FakeBalWindow(audio_plugin=None)
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.receive_btn.isEnabled()
assert "not available" in d.audio_warn_label.text()
assert d.qr_page is not None # QR import still usable
d.close()
def test_audio_import_receive_wiring():
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
captured = {}
class FakeWaitingDialog:
def __init__(self, parent, msg, task, on_success=None, on_error=None):
captured["msg"] = msg
captured["task"] = task
captured["success"] = on_success
captured["error"] = on_error
imported = []
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
imported.append(payload)
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
dialogs, "_complete_import", side_effect=fake_complete
):
d._audio_receive()
kbps = int(d.kbps_combo.currentText())
assert bw.bitrate_set == kbps
assert captured["task"] is not None
captured["success"](blob)
# Payload is the raw decompressed text; autodetect handles the splitting.
assert imported == ["A" * 40 + "\n" + "B" * 40]
d.close()
# ------------------------------------------------------------------ #
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
# ------------------------------------------------------------------ #
def test_qr_whole_will_json_serializes_transaction():
"""Regression: _whole_will_json must not raise
"Object of type Transaction is not JSON serializable".
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
QR payload (default content scope) must serialize it via MyEncoder the
same way write_json_file does.
"""
from bal.core.will import WillItem
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
})
bw = FakeBalWindow()
d = dialogs.WillExportDialog(
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
)
j = d._whole_will_json()
data = json.loads(j)
assert data["imp0"]["tx"] == _VALID_TX_HEX
d.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
import os
import tempfile
class _Path:
def __init__(self, d, name):
self.strpath = os.path.join(d, name)
class _Tmp:
def __init__(self):
self._d = tempfile.mkdtemp()
def join(self, name):
return _Path(self._d, name)
tmp = _Tmp()
for name in sorted(dir()):
if name.startswith("test_"):
fn = globals()[name]
fn(tmp) if inspect.signature(fn).parameters else fn()
print(" [OK] {}".format(name))
print("[OK] All export dialog GUI tests passed")

View File

@@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh():
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
is_basic_mode=lambda: False,
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
@@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh():
schedule_mock.assert_called_once_with()
def test_rebuild_purges_stale_wallet_history_before_building():
# The rebuild path must drop stale wallet-LOCAL will placeholders (saved by
# an earlier prepare) so their coins are available to the new build.
win = object.__new__(BalWindow)
win.disable_plugin = False
win.heirs = {"h": object()}
win.willexecutors = {}
win.no_willexecutor = True
win.willitems = {}
win.will = {}
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
is_basic_mode=lambda: False,
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
)
win.window = _FakeWindow()
win.window.wallet = _Wallet()
with (
patch.object(Util, "get_available_utxos", return_value=[]),
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
patch.object(Will, "get_min_locktime", return_value=0),
patch.object(Will, "check_amounts"),
patch.object(BalWindow, "init_class_variables"),
patch.object(BalWindow, "build_will"),
patch.object(
BalWindow,
"check_will",
side_effect=[NotCompleteWillException(), None],
),
patch.object(BalWindow, "update_all"),
patch.object(BalWindow, "_schedule_history_refresh"),
patch.object(Will, "remove_stale_wallet_history") as purge_mock,
):
BalWindow.build_inheritance_transaction(win)
purge_mock.assert_called_once_with(win.window.wallet, "LBL")
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -0,0 +1,760 @@
"""
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
total mismatch reset, frame assembly -> decode). The wizard and the
camera/audio paths need a live wallet/hardware and are exercised only
through the shared frame-assembly path here.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
"""
import base64
import json
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from electrum.transaction import Transaction
from PyQt6.QtWidgets import QApplication, QMainWindow
import bal.gui.qt.dialogs as dialogs
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames
from bal.core.will import WillItem
_app = QApplication.instance() or QApplication(sys.argv)
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
_PNG_BYTES = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output),
# reused for the WillItem status regression test.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class FakePlugin:
# Smallest QR preset: long transfers produce several frames.
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
def read_file(self, path):
return _PNG_BYTES
class FakeWindow(QMainWindow):
config = {}
def format_amount(self, amount):
return "{:.8f}".format(amount)
def format_amount_and_units(self, amount):
return "{:.8f} sat".format(amount)
class FakeBalWindow:
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
def __init__(self):
self.window = FakeWindow()
self.bal_plugin = FakePlugin()
self.willitems = {}
def get_audio_modem_plugin(self):
return None
class StubTx:
def __init__(self, payload):
self.payload = payload
def txid(self):
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
def __str__(self):
return self.payload
class StubWillItem:
def __init__(self, payload, statuses=None):
self.tx = StubTx(payload)
self.statuses = statuses or {}
def get_status(self, name):
return self.statuses.get(name, False)
def to_dict(self):
return {"tx": str(self.tx), "status": self.statuses}
def _make_willitems(n=3, payload_len=120, payloads=None):
if payloads is None:
payloads = ["T{}".format(i) * payload_len for i in range(n)]
return {
"item{}".format(i): StubWillItem(p)
for i, p in enumerate(payloads)
}
# ------------------------------------------------------------------ #
# WillExportDialog (QR transport via d.qr_page)
# ------------------------------------------------------------------ #
def test_export_dialog_builds():
bw = FakeBalWindow()
bw.willitems = _make_willitems()
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert d.transport_qr.isChecked()
assert page.tx_strings
assert page.frames
assert len(page.frames) >= 1
# Frame 1 is shown.
assert page.qr_view.text == page.frames[0]
assert "1" in page.progress_label.text()
d.close()
def test_export_dialog_unified_transports():
# One window hosts the three transports as stacked, radio-selected pages.
bw = FakeBalWindow()
bw.willitems = _make_willitems()
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert d.stacked.count() == 3
assert d.transport_file.isChecked()
assert d.stacked.currentWidget() is d.file_page
d._on_mode_clicked(d.MODE_QR)
assert d.stacked.currentWidget() is d.qr_page
d._on_mode_clicked(d.MODE_AUDIO)
assert d.stacked.currentWidget() is d.audio_page
d.close()
def test_import_dialog_qr_page_has_no_audio():
# Audio lives on the import dialog's own audio page, never in the QR page.
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
assert not hasattr(page, "_audio_receive")
texts = [b.text() for b in page.findChildren(dialogs.QPushButton)]
assert not any("Audio" in t for t in texts)
assert d.receive_btn is not None
d.close()
def test_export_dialog_empty_close():
# An empty will shows a modal message and no widgets are built; stub the
# message out for the test.
orig = dialogs.MessageBoxMixin.show_message
dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None
try:
bw = FakeBalWindow()
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
assert not d.isVisible()
assert not hasattr(d, "qr_page")
d.close()
finally:
dialogs.MessageBoxMixin.show_message = orig
def test_imported_item_status_not_none():
# Regression: a WillItem built from a bare {"tx": hex} had a None status,
# so set_status (e.g. IMPORTED / INVALIDATED from the import validity
# pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and
# 'str'".
with patch.object(Transaction, "add_info_from_wallet"):
wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None)
assert wi.status == ""
assert wi.set_status("IMPORTED", True) is True
assert wi.set_status("INVALIDATED", True) is True
assert "Imported" in wi.status and "Invalidated" in wi.status
def test_export_auto_scroll():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert page.fps_spin is not None
assert not page.auto_timer.isActive()
page.fps_spin.setValue(2)
page._toggle_auto()
assert page.auto_timer.isActive()
assert page.auto_btn.text() == dialogs._("Stop")
page._auto_step()
assert page.index == 1
page._toggle_auto()
assert not page.auto_timer.isActive()
assert page.auto_btn.text() == dialogs._("Auto")
# Advancing past the last frame stops the slideshow automatically.
page._toggle_auto()
page.index = len(page.frames) - 1
page._auto_step()
assert not page.auto_timer.isActive()
d.close()
def test_export_auto_scroll_loop():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert page.loop_check is not None
# Loop on: reaching the last code wraps back to the first and keeps going.
page.loop_check.setChecked(True)
page._toggle_auto()
assert page.auto_timer.isActive()
page.index = len(page.frames) - 1
page._auto_step()
assert page.index == 0
assert page.auto_timer.isActive()
page._toggle_auto()
# Loop off: reaching the last code stops the slideshow.
page.loop_check.setChecked(False)
page._toggle_auto()
page.index = len(page.frames) - 1
page._auto_step()
assert not page.auto_timer.isActive()
d.close()
def test_export_filter_valid_and_valid_nc():
bw = FakeBalWindow()
a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
b = StubWillItem("B" * 120, statuses={"VALID": True})
c = StubWillItem("C" * 120)
bw.willitems = {"a": a, "b": b, "c": c}
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert d.filter_combo.count() == 3
# Whole-will default: a single JSON document carrying all selected items.
assert d.content_check.isChecked()
assert len(page.tx_strings) == 1
data = json.loads(page.tx_strings[0])
assert set(data) == {"a", "b", "c"}
# Switch to tx-only content, then exercise the filters.
d.content_check.setChecked(False)
assert len(page.tx_strings) == 3
# "Valid" filter -> only the valid items (a, b).
d._on_filter_change(1)
assert sorted(page.tx_strings) == ["A" * 120, "B" * 120]
# "Valid NC" filter -> only the valid, not-complete item (b).
d._on_filter_change(2)
assert sorted(page.tx_strings) == ["B" * 120]
assert page.qr_view.text == page.frames[0]
d.close()
def test_export_filter_empty_reverts():
bw = FakeBalWindow()
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
bw.willitems = {
"a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True})
}
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
messages = []
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
d._on_filter_change(2) # "Valid NC" -> empty subset
assert messages
assert d._filter_index == 0 # reverted to "All"
assert d.filter_combo.currentIndex() == 0
assert len(d.qr_page.tx_strings) == 1
d.close()
def test_export_navigation_and_chunk_change():
# Low-redundancy serialized transactions resist deflate, so even the
# compressed best-of transfer still needs several frames at the default
# chunk and navigation across frames is exercised.
def noisy(pad):
return "".join("{:02x}".format((pad * 31 + j * 101 + j * j) % 256) for j in range(200))
bw = FakeBalWindow()
bw.willitems = _make_willitems(
n=6, payload_len=400,
payloads=["{}0{}".format(noisy(i), "T" * 50) for i in range(6)],
)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
first_count = len(page.frames)
assert first_count > 1 # long transfer, small default chunk
assert not page.prev_btn.isEnabled()
page._next()
assert page.index == 1
assert page.qr_view.text == page.frames[1]
assert page.prev_btn.isEnabled()
page._prev()
assert page.index == 0
assert page.qr_view.text == page.frames[0]
# Switch to the largest preset: fewer, bigger frames.
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
assert len(page.frames) < first_count
assert page.index == 0
d.close()
# ------------------------------------------------------------------ #
# WillImportDialog (QR transport via d.qr_page)
# ------------------------------------------------------------------ #
def test_import_frame_flow():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
assert not page.review_btn.isEnabled()
assert not page.slot_area.isVisible()
transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120])
frames = split_frames(transfer, 150)
assert len(frames) > 1
for frame in frames:
page._add_frame(frame)
assert page.total == len(frames)
assert page.review_btn.isEnabled()
# isVisible() needs a shown parent; assert the widget is not hidden instead.
assert not page.slot_area.isHidden()
assert len(page.slot_widgets) == page.total
assert "All" in page.status_label.text()
# Duplicate capture is harmless.
page._add_frame(frames[0])
assert len(page.frames) == page.total
d.close()
def test_import_assembles_and_decodes():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
captured = {}
def fake_finish(payload):
captured["payload"] = payload
page._finish_import = fake_finish
transfer = encode_transfer(["P" * 130, "Q" * 130])
for frame in split_frames(transfer, 150):
page._add_frame(frame)
page._review_and_sign()
# The payload is rejoined into an opaque string for autodetect.
assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130]
d.close()
def test_decode_will_payload_autodetect():
# Whole-will JSON is recognized as a will document.
payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}})
kind, data = dialogs.decode_will_payload(payload)
assert kind == "will"
assert data["item1"]["tx"] == "AAAA"
# A singleton dict whose value is not an item dict falls back to txs.
kind, data = dialogs.decode_will_payload('{"foo": 1}')
assert kind == "txs"
# Comma and/or newline separated transactions.
kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC")
assert kind == "txs"
assert data == ["AAAA", "BBBB", "CCCC"]
# A single transaction with no separators.
kind, data = dialogs.decode_will_payload("HEXHEX")
assert kind == "txs"
assert data == ["HEXHEX"]
def test_whole_will_qr_roundtrip():
# "Whole will" produces a single JSON document that survives a full
# QR encode -> frame capture -> assemble -> decode cycle.
bw = FakeBalWindow()
items = _make_willitems(2)
bw.willitems = items
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert d.content_check.isChecked()
assert len(page.tx_strings) == 1
# Rebuild the transfer from the dialog's own strings, as the importer does.
transfer = encode_transfer(page.tx_strings)
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page
for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
import_page._add_frame(frame)
assert import_page.review_btn.isEnabled()
caught = {}
def fake_finish(payload):
caught["payload"] = payload
import_page._finish_import = fake_finish
import_page._review_and_sign()
kind, data = dialogs.decode_will_payload(caught["payload"])
assert kind == "will"
assert set(data) == {"item0", "item1"}
d.close()
impl.close()
def test_import_total_mismatch_resets():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
for frame in frames_a:
page._add_frame(frame)
assert page.total == len(frames_a)
# A frame with a different total wipes the import; the first frame of
# the new transfer must be scanned afresh.
page._add_frame(frames_b[0])
assert warnings
assert page.total == 0
assert not page.frames
assert not page.review_btn.isEnabled()
d.close()
def test_import_manual_entry():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
frames = split_frames(encode_transfer(["M" * 120]), 150)
page.manual_edit.setText(frames[0])
page._add_from_manual()
assert page.manual_edit.text() == ""
assert page.total == 1
assert page.review_btn.isEnabled()
d.close()
# ------------------------------------------------------------------ #
# Continuous camera scan (change/detection debounce + auto-finish)
# ------------------------------------------------------------------ #
def _fresh_debounce():
return {
"last_index": None,
"last_payload": None,
"pending_index": None,
"pending_payload": None,
"pending_count": 0,
}
def test_qr_import_debounce_pending_then_accept():
s = _fresh_debounce()
# First sighting of a new identity: pending, not yet stored.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending"
assert s["pending_count"] == 1
assert s["last_index"] is None
# A second stable read of the same identity: accepted.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept"
assert s["last_index"] == 1
assert s["last_payload"] == "PAYLOAD1"
assert s["pending_count"] == 0
def test_qr_import_debounce_re_reading_last_is_ignored():
s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted
# The exporter is still showing frame 1: must be ignored, not accepted.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore"
assert s["last_index"] == 1
assert s["pending_count"] == 0
def test_qr_import_debounce_transition_pending_resets():
s = _fresh_debounce()
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1")
dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1
# A new identity interrupts the pending accumulation.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending"
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept"
# Same-index duplicate with different payload is treated as new identity.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore"
def test_qr_import_debounce_total_mismatch_resets():
s = _fresh_debounce()
# In-range frame is accepted even though its declared total is ignored.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending"
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept"
# A frame that belongs to a different transfer.
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset"
# Once the policy is rebased on the new transfer, frames resume normally
# (the widget clears the debounce while wiping the import).
s["key"] = None
assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending"
def test_qr_import_handle_scanned_text_autofinish():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
assert len(frames) > 1
# The camera session is running and every frame needs two stable reads.
page._scanning = True
for frame in frames:
for _rep in range(2):
page._handle_scanned_text(frame)
assert page.total == len(frames)
assert len(page.frames) == len(frames)
assert page.review_btn.isEnabled()
# With all frames stored, the loop auto-finishes exactly once.
_app.processEvents()
assert reviewed == [True]
# The camera loop was stopped before handing over to the review step.
assert not page._scanning
assert not page._scan_timer.isActive()
d.close()
def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
# Without a camera session running, extra frames never auto-proceed.
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
reviewed = []
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
assert len(frames) > 1
assert not page._scanning
for frame in frames:
for _rep in range(2):
page._handle_scanned_text(frame)
assert len(page.frames) == len(frames)
_app.processEvents()
assert reviewed == []
d.close()
# ------------------------------------------------------------------ #
# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages
# ------------------------------------------------------------------ #
def test_export_format_combo_switches_codecs():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
assert page.format == "balqr"
assert page.frames[0].startswith("BAL1")
assert page.format_combo.count() == 4
page._on_format_change(1) # BC-UR v1
assert page.format == "ur1"
assert page.frames[0].startswith("ur:bytes/")
assert page.index == 0
assert page.qr_view.text == page.frames[0]
page._on_format_change(2) # BC-UR v2
assert page.format == "ur2"
assert page.frames[0].startswith("ur:bytes/")
page._on_format_change(3) # BBQR
assert page.format == "bbqr"
assert page.frames[0].startswith("B$")
d.close()
def test_export_animated_format_frames_fit_budget():
bw = FakeBalWindow()
bw.willitems = _make_willitems(n=6, payload_len=400)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
for index in range(1, 4):
page._on_format_change(index)
for frame in page.frames:
assert len(frame) <= dialogs.CHUNK_PRESETS[0][1]
d.close()
def _import_roundtrip_fmt(fmt_index):
bw = FakeBalWindow()
bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
page._on_format_change(fmt_index)
frames = list(page.frames)
assert frames
d.close()
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page
caught = {}
import_page._finish_import = lambda payload: caught.__setitem__("payload", payload)
for frame in frames:
import_page._add_frame(frame)
assert import_page.review_btn.isEnabled()
import_page._review_and_sign()
kind, data = dialogs.decode_will_payload(caught["payload"])
assert kind == "will"
assert set(data) == {"item0", "item1"}
impl.close()
def test_import_ur1_roundtrip():
_import_roundtrip_fmt(1)
def test_import_ur2_roundtrip():
_import_roundtrip_fmt(2)
def test_import_bbqr_roundtrip():
_import_roundtrip_fmt(3)
def test_import_animated_scan_debounce_autofinish():
bw = FakeBalWindow()
bw.willitems = _make_willitems(2)
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
page = d.qr_page
page._on_format_change(2) # BC-UR v2 fountain
frames = list(page.frames)
d.close()
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
import_page = impl.qr_page
reviewed = []
import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
import_page._scanning = True
for frame in frames:
for _rep in range(2):
import_page._handle_scanned_text(frame)
assert import_page.review_btn.isEnabled()
# The fountain transfer's part count is ``len(frames) // 2`` (pure + one
# redundant mixed wave).
assert import_page.total == len(frames) // 2
assert len(import_page.frames) >= import_page.total
assert import_page.review_btn.isEnabled()
_app.processEvents()
assert reviewed == [True]
assert not import_page._scanning
impl.close()
def test_import_garbage_scan_is_ignored():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
for garbage in ("hello world", "12345", "B$ZZ", ""):
page._handle_scanned_text(garbage)
assert not page.frames
assert page.total == 0
assert not page.review_btn.isEnabled()
d.close()
def test_import_different_animated_transfer_resets():
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
warnings = []
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150)
frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150)
for frame in frames_a:
page._add_frame(frame)
assert page.total == len(frames_a)
assert not warnings
page._add_frame(frames_b[0])
assert warnings
assert page.total == 0
assert not page.frames
assert not page.review_btn.isEnabled()
d.close()
def test_import_start_stop_scan_signal_wiring():
"""Regression: _start_scan/_stop_scan must use the QVideoSink signal
videoFrameChanged, not the videoFrame frame getter.
On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so
``.videoFrame.connect(...)`` raises AttributeError. This test drives the
real sink life-cycle with a mocked camera and asserts the scan session
starts/ends cleanly with no error.
"""
from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices
bw = FakeBalWindow()
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
page = d.qr_page
errors = []
page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment]
fake_device = MagicMock()
fake_device.isNull.return_value = False
with (
patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device),
# Mock camera + capture session; the QVideoSink stays real so the
# videoFrameChanged connect/disconnect wiring is exercised for real.
patch.object(QCamera, "__new__", return_value=MagicMock()),
patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()),
):
page._start_scan()
assert page._scanning is True
assert not errors
page._stop_scan()
assert page._scanning is False
assert page._camera is None
assert page._video_sink is None
assert not errors
d.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All QR transfer GUI tests passed")

View File

@@ -390,6 +390,110 @@ def test_insufficient_funds_warns():
assert not ctl.willitems
def test_guard_not_blocked_by_old_built_will():
"""Regression: shortening the delivery in the STORED settings (relative
"1y"/"30d") while an old, still-VALID built will is frozen at a longer
locktime must NOT fire the "locktime is lower than threshold" guard.
The old guard compared the fresh settings locktime against ``date_to_check``
anchored to the built will (see ``resolve_date_to_check``), so a built-will
delivery longer than the settings' one made it fire even though the settings
are internally consistent (locktime is 30d AFTER the threshold). The guard
must instead compare the stored settings on a single reference frame
(``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its
built anchor for the expiry/validity checks.
"""
with _no_willexecutors():
ctl = make_controller()
ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode
ctl.prepare_will()
txid, item = _single(ctl)
# Freeze the built (VALID) will at a delivery one year longer than the
# now-shortened settings: the pre-fix guard would reject the rebuild.
item.tx.locktime = item.tx.locktime + 365 * 86400
ctl.will_settings = {"locktime": "1y", "threshold": "30d"}
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.init_class_variables()
# date_to_check is anchored to the built will (long delivery)...
assert ctl.date_to_check == item.tx.locktime - 30 * 86400
# ...and the OLD guard would have fired here:
old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"])
assert old_locktime < ctl.date_to_check
# but the settings themselves are consistent, so the guard must pass:
assert ctl.is_locktime_below_threshold() is False
assert not ctl.window.errors
def test_anticipated_rebuild_reanchors_date_to_check():
"""Regression (karen7): rebuilding a SIGNED will whose delivery was
anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must
succeed.
``date_to_check`` stays anchored to the OLD built delivery for the validity
checks, but ``build_will`` must re-anchor it to the NEW (earliest current)
delivery as its build filter: before the fix the stale 2028 anchor rejected
every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build
reported ``NO_FUTURE_DATE``. The old signed item is then superseded by
``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will
is coherent again.
"""
with _no_willexecutors():
ctl = make_controller()
ctl.bal_plugin.USER_TYPE.set("advanced")
# Per-heir deliveries require multiverse mode (the only way heirs can
# carry a different recipe than the settings locktime).
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20}
Util.fix_will_settings_tx_fees(ctl.will_settings)
ctl.heirs["alice"][2] = "2y"
ctl.heirs["bob"][2] = "2y"
# Build and sign a 2y will (the old, committed delivery).
ctl.prepare_will()
old_txid, _old_item = _single(ctl)
old_locktime = _old_item.tx.locktime
signed = ctl.sign_transactions(None)
_old_item.tx = Will.get_tx_from_any(str(signed[old_txid]))
Will.check_signatures(ctl.willitems, ctl.wallet)
assert _old_item.get_status("COMPLETE")
# Anticipate: shorten every heir to 1y.
ctl.heirs["alice"][2] = "1y"
ctl.heirs["bob"][2] = "1y"
ctl.init_class_variables()
# date_to_check stays anchored to the OLD built delivery...
assert ctl.date_to_check == old_locktime - 150 * 86400
# ...and that stale anchor would reject the anticipated "1y" dates.
assert Util.parse_locktime_string("1y") < ctl.date_to_check
# The rebuild must succeed (re-anchored to the new delivery).
willitems = ctl.build_inheritance_transaction()
assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire"
new_valid = [
it for tid, it in willitems.items()
if tid != old_txid and it.get_status("VALID")
]
assert new_valid, "the anticipated (1y) will must build and stay VALID"
new_item = new_valid[0]
assert new_item.tx.locktime < old_locktime, "delivery must be anticipated"
# date_to_check was re-anchored to the rebuilt delivery (1y minus 150d).
assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600
# The old signed item is kept but superseded (REPLACED -> not VALID).
assert _old_item.get_status("REPLACED") is True
assert _old_item.get_status("VALID") is False
# The rebuilt will is coherent (plain rebuild, no on-chain invalidation).
assert ctl.check_will() is True
assert not any("delivery date" in m for m in ctl.window.messages)
assert not ctl.window.errors
def _run_all():
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
for fn in tests:

View File

@@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here:
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
The reported state (reproduced hermetically here — the original live wallet
dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is:
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:
@@ -28,18 +29,16 @@ Run:
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))
import pytest # noqa: E402 (path insert above)
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.util import copy_structure # noqa: E402
from bal.core.will import ( # noqa: E402
HeirNotFoundException,
NoHeirsException,
@@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402
WillPostponedException,
)
@pytest.fixture(autouse=True)
def _regtest_net():
"""Run these regtest-focused tests with BitcoinRegtest, restoring mainnet
afterwards so sibling test modules are unaffected by the net switch."""
constants.net = constants.BitcoinRegtest
yield
constants.net = constants.BitcoinMainnet
# 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 = (
@@ -71,7 +80,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"heirs": copy_structure(heirs),
"willexecutor": None,
"status": "",
"description": "",
@@ -80,7 +89,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
"baltx_fees": 1,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
@@ -111,7 +120,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
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
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
@@ -119,7 +128,7 @@ def test_unchanged_relative_recipe_signed_is_coherent():
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
copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=False
)
assert outcome.startswith("coherent"), outcome
@@ -145,7 +154,7 @@ def test_relative_recipe_shortened_on_signed_is_rebuild():
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
copy_structure(built), copy_structure(built), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
@@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected():
# ------------------------------------------------------------------ #
# karen7 wallet regression (real fixture)
# karen7 regression (hermetic, no live wallet fixture)
# ------------------------------------------------------------------ #
def _load_karen7():
path = os.path.join(os.path.dirname(__file__), "karen7")
with open(path) as f:
return json.load(f)
# karen7's reported state, reproduced hermetically: heirs "1y", a signed item
# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a
# relative "150d" delivery window and a "2y" promised locktime.
_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"}
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 = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime == int(wi.tx.locktime)
heirs = {"alice": ["addr_alice", 5000, "1y"]}
item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True)
will = {"willid_1": item}
built_locktime = Will.get_min_locktime(will)
assert built_locktime is not None
assert built_locktime == int(item.tx.locktime)
date_to_check = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=built_locktime
)
assert int(date_to_check) < built_locktime
# 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,
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 "2d") are coherent with the frozen
signed tx: the plugin must NOT ask to invalidate the will."""
data = _load_karen7()
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
"""Unchanged relative "1y" heirs are coherent with the frozen signed tx:
the plugin must NOT ask to invalidate the will."""
heirs = {"alice": ["addr_alice", 5000, "1y"]}
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
# the UTC anchoring code.
frozen_locktime = _FROZEN
date_to_check = resolve_date_to_check(
False, data["will_settings"],
False, _WILL_SETTINGS,
now=1_800_000_000.0,
built_locktime=frozen_locktime,
)
outcome = _run_heir_check(
data["will"][valid_wid]["heirs"],
data["heirs"],
copy_structure(heirs),
copy_structure(heirs),
frozen_locktime,
status_complete=True,
)
@@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent():
# ------------------------------------------------------------------ #
if __name__ == "__main__":
constants.net = constants.BitcoinRegtest
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()

View File

@@ -127,6 +127,11 @@ def test_sign_transactions_external_only():
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
# sign_transactions dispatches to self._prepare_and_sign_tx; bind the real
# implementation onto the fake so the external-sign run actually executes.
fake._prepare_and_sign_tx = MethodType(
window_mod.BalWindow._prepare_and_sign_tx, fake
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
@@ -166,6 +171,50 @@ def test_will_widget_explicit_will():
assert w2.will is live
# ------------------------------------------------------------------ #
# WillWidget shows heir/willexecutor addresses and decodes OP_RETURN
# ------------------------------------------------------------------ #
def test_will_widget_shows_addresses_and_decodes_opreturn():
from PyQt6.QtWidgets import QLabel
from bal.core.will import WillItem
from bal.gui.qt.widgets import WillWidget
op_hex = "68656c6c6f" # "hello" in hex
heirs = {
"bob": ["bc1qtestaddr", 1_000_000, 1000, 500_000],
"msg": [f"OP_RETURN:{op_hex}", 0, 1000, 0],
}
wi = WillItem(
_make_willitem_dict(
heirs=heirs,
willexecutor={
"url": "https://exec.example",
"address": "bc1qexecaddr",
"base_fee": 200_000,
},
)
)
wi._id = "w0"
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems={},
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will={"w0": wi})
texts = [lbl.text() for lbl in w.findChildren(QLabel)]
assert any("bc1qtestaddr" in t for t in texts), texts
assert any("OP_RETURN: hello" in t for t in texts), texts
assert any("bc1qexecaddr" in t for t in texts), texts
# ------------------------------------------------------------------ #
# WillDetailDialog external-will mode
# ------------------------------------------------------------------ #

View File

@@ -27,7 +27,6 @@ Run::
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
"""
import copy
import json
import logging
import os
@@ -49,6 +48,7 @@ from electrum.transaction import PartialTxInput, TxOutpoint
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.util import copy_structure
from bal.core.will import (
NotCompleteWillException,
NoWillExecutorNotPresent,
@@ -278,11 +278,11 @@ class FakeBalWindow:
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["heirs"] = copy_structure(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
self.update_will(will)

View File

@@ -7,7 +7,6 @@ but without requiring a full Qt event loop.
"""
import contextlib
import copy
import json
import os
import sys
@@ -24,6 +23,7 @@ if os.path.isdir(ELECTRUM_DIR):
from bal.core.heirs import Heirs
from bal.core.plugin_base import BalPlugin, BalTimestamp
from bal.core.util import copy_structure
from bal.core.will import (
NoHeirsException,
NotCompleteWillException,
@@ -145,11 +145,11 @@ class FakeBalWindow:
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["heirs"] = copy_structure(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
Will.update_will(self.willitems, will)

View File

@@ -2,13 +2,17 @@
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):
(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed
locktime when the core AUTOMATICALLY anticipates it (one day earlier than
stored).
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".
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
that once forced freezing them to absolute timestamps is solved at the root by
anchoring every relative recipe against the built transactions
(``Util.resolve_locktime_against_tx`` for the postpone detection,
``resolve_date_to_check(..., built_locktime=...)`` for the reference
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
moves the stored value.
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
Electrum wallet) by calling it as an unbound method.
@@ -24,7 +28,6 @@ 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)
# ------------------------------------------------------------------ #
@@ -68,34 +71,30 @@ def _call_sync(will_settings, tx_locktimes, recorded):
# 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."""
def test_relative_locktime_preserved():
"""A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild:
it is anchored against the built transactions on every check, so it must
not be frozen to an absolute timestamp in WILL_SETTINGS."""
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.bal_window.will_settings["locktime"] == "90d"
assert fake.bal_window.will_settings["threshold"] == "30d"
assert recorded == [], "a relative recipe must never be rewritten"
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)."""
def test_relative_threshold_preserved():
"""Same for the relative "Check Alive" threshold: it stays relative."""
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
assert fake.bal_window.will_settings["threshold"] == "30d"
assert recorded == []
def test_absolute_locktime_unchanged_on_equal():
@@ -113,8 +112,8 @@ def test_absolute_locktime_unchanged_on_equal():
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."""
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
than stored) still moves the date earlier and flags the sign prompt."""
tx_locktime = 1_700_000_000
recorded = []
fake = _call_sync(
@@ -129,7 +128,7 @@ def test_anticipation_sets_flag_and_moves_earlier():
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."""
anticipation (built < stored) moves the value."""
stored = 1_800_000_000
recorded = []
fake = _call_sync(
@@ -142,39 +141,39 @@ def test_stored_earlier_than_built_never_moved_later():
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)."""
"""When several ABSOLUTE transactions carry different locktimes, the minimum
is used for a genuine automatic anticipation (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"},
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
[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
def test_relative_locktime_stays_coherent_via_anchor():
"""Daily-drift guard: an UNCHANGED relative recipe is resolved against the
tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT
being frozen to an absolute value it still reads as COHERENT (== tx
locktime) on later days - the postpone check never fires again."""
from datetime import datetime, timedelta, timezone
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"]
# resolve_locktime_against_tx normalises to UTC midnight before anchoring,
# so use a midnight-UTC frozen tx locktime (the timestamp the engine itself
# stores after building).
tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp())
built = "90d" # recipe frozen at build time
current = "90d" # unchanged recipe today
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).
resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime)
assert resolved == tx_locktime # no POSTPONE / drift
# Sanity: a naive forward-from-now re-parse would have drifted past it
# (the bug the anchor fixes).
drifted = int(
(
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)