core+gui: compact BAL QR v2 wire format + best-of compression default

BAL QR export now ships a fixed 11-char 'BAL1<TTT><iii><F><payload>' header
(3-char base36 zero-padded count fields, cap 46655 frames) and, by default,
the shorter of plain vs zlib+base64 (flag '0'/'Z'). parse_frame remains dual:
legacy 'BALQR1|total|index|flags|' frames still import. detect_format accepts
'BAL1'. New exports are not readable by builds older than this change.

Also add a will-detail test for heir/willexecutor addresses and decoded
OP_RETURN payloads. Docs updated (CHANGELOG, COMPATIBILITY, PLAN_QR_TRANSFER,
README); 345 core tests + 89 GUI tests pass.
This commit is contained in:
2026-09-14 04:42:30 -04:00
parent c040d5323c
commit 7bf05172b3
10 changed files with 391 additions and 79 deletions

View File

@@ -426,6 +426,7 @@ def test_bbqr_part_number_limits():
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"
@@ -444,6 +445,9 @@ def test_detect_format_rejects_garbage():
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])

View File

@@ -100,9 +100,9 @@ def test_size_greater_than_payload():
def test_exact_single_frame_boundary():
# A 138-byte payload exactly fills the 150-byte preset budget (the 12-char
# empty-flags header plus payload), so the encoded frame is exactly 150.
tx_strings = ["a" * 138]
# 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
@@ -157,6 +157,119 @@ def test_compressed_roundtrip_through_frames():
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
# --------------------------------------------------------------------------- #

View File

@@ -107,10 +107,12 @@ class StubWillItem:
return {"tx": str(self.tx), "status": self.statuses}
def _make_willitems(n=3, payload_len=120):
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("T{}".format(i) * payload_len)
for i in range(n)
"item{}".format(i): StubWillItem(p)
for i, p in enumerate(payloads)
}
@@ -288,8 +290,17 @@ def test_export_filter_empty_reverts():
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)
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)
@@ -568,7 +579,7 @@ def test_export_format_combo_switches_codecs():
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("BALQR1|")
assert page.frames[0].startswith("BAL1")
assert page.format_combo.count() == 4
page._on_format_change(1) # BC-UR v1