feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests
This commit is contained in:
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal 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
|
||||
2700
tests/karen7
2700
tests/karen7
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
482
tests/test_core_animated_qr.py
Normal file
482
tests/test_core_animated_qr.py
Normal 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")
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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():
|
||||
|
||||
439
tests/test_core_qr_transfer.py
Normal file
439
tests/test_core_qr_transfer.py
Normal 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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
456
tests/test_gui_export_dialogs.py
Normal file
456
tests/test_gui_export_dialogs.py
Normal 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")
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
760
tests/test_gui_qr_transfer.py
Normal file
760
tests/test_gui_qr_transfer.py
Normal 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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user