diff --git a/CHANGELOG.md b/CHANGELOG.md index b81787a..1576e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3265,3 +3265,65 @@ as the default. `external_zip_test.py` all pass. **Outcome:** DONE (uncommitted). + +--- + +## Balanced-QR wire format v2: compact header + best-of compression + +**Date:** 2026-09-13 + +**Goal:** Shrink the native BAL QR wire format to its minimum. The old +pipe-separated header (`BALQR1|total|index|flags|`) wasted 12-14 characters on +direction marker, separators and decimal count fields, and the export always +sent uncompressed hex text. New exports should fit a will in the fewest, +densest frames possible. + +**What changed:** + +- `bal/core/qrtransfer.py`: + - New wire format v2: `BAL1` — fixed 11-char header, + no separators. `BAL1` magic, 3-digit **base36** zero-padded totals/index + (values `00A`-`ZZZ`, cap 46655 frames), single flag char. + - Flags: `0` = plain payload, `Z` = zlib+base64 compressed payload (the importer + already decompressed `Z`; the exporter now produces it). + - `encode_transfer_best(tx_strings)` returns the shorter of plain vs + compressed; the export widget uses it as the default for BAL QR. + - `parse_frame` is dual: old `BALQR1|total|index|flags|payload` frames still + import unchanged (backwards-compatible receive). + - Frame-count overflow (a transfer needing > 46655 frames) raises + `QrTransferError` at encode time instead of emitting corrupt headers. +- `bal/gui/qt/dialogs.py` (`BalQrExportWidget`): BAL QR export now encodes via + `encode_transfer_best`, so plain *or* compressed frames are emitted per + transfer; import is untouched (already format-agnostic and flag-driven). +- `bal/core/animated_qr.py`: `detect_format` accepts `BAL1` in addition to the + legacy `BALQR` prefix; wire-format docstring updated. +- `bal/gui/qt/widgets.py` (`WillWidget`): the will detail view now shows each + heir's address (or the decoded UTF-8 text of an `OP_RETURN:` heir) and a + dedicated Address row for the will-executor. + +**Verification:** + +- `tests/test_core_qr_transfer.py` (new v2 tests: header structure, field width, + `Z` flag round-trip, best-of selection, malformed `BAL1` frames, 46655 cap and + exact boundary): all pass; legacy `BALQR1` parse tests unchanged and green. +- `tests/test_core_animated_qr.py`: `BAL1` detection + `parse_for_detection`; + `tests/test_gui_qr_transfer.py`: format-combo + chunk-navigation updated for + the compact export. +- `pytest tests/test_core_*.py tests/test_import_will_details.py -q`: 345 passed. +- `ruff` clean on all touched files except the pre-existing `dialogs.py` I001 + (present on HEAD); `pyright` 0 errors on the codec modules. +- Android Chaquopy bundle re-synced (`sync_codecs.py` + `verify_chain.py`). + +**Notes / caveats:** + +- **Compatibility break (forward):** the new default export (compressed + `BAL1…`) is NOT readable by older BAL versions — nor by the previously + released Android APK — until those are updated to accept `BAL1`. Imports of + legacy `BALQR1…` exports keep working on this version. Existing audio + transfers are unaffected (they keep explicit `compress=False`, and the audio + format was never flag-driven on receive). +- A typical multi-tx will now ships as a single dense frame instead of two + sparse ones: header overhead dropped from 12-14 chars to a constant 11, and + the base36 count fields are 3 chars regardless of how many frames exist. + +**Outcome:** DONE. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d5db6e7..a42079d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -31,7 +31,7 @@ supports **BC-UR v1**, **BC-UR v2** and **BBQR**: | Format | Wire appearance | Interop target | |-----------|----------------------------|------------------------------------------------------| -| BAL QR | `BALQR1\|total\|index\|…` | Past/other BAL versions (default, always exported) | +| BAL QR | `BAL1…` (v2) / `BALQR1\|total\|index\|…` (legacy import-only) | Past/other BAL versions: **v2 exports are NOT readable by old builds**; old `BALQR1` exports still import here (default, best-of compression, flag `0` = plain, `Z` = deflate) | | BC-UR v1 | `ur:bytes/` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) | | BC-UR v2 | `ur:bytes/-/` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) | | BBQR | `B$…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode | diff --git a/PLAN_QR_TRANSFER.md b/PLAN_QR_TRANSFER.md index a4b1a8d..d371019 100644 --- a/PLAN_QR_TRANSFER.md +++ b/PLAN_QR_TRANSFER.md @@ -62,9 +62,9 @@ | # | Decision | |---|----------| | D1 | QML part = update `QML_PLAN.md` document only; implementation later. | -| D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. | +| D2 | Frames carry a small **compact** ASCII header (`BAL1`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. | | D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. | -| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. | +| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. | | D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. | | D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. | | D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. | @@ -120,19 +120,30 @@ transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid ) ### 4.2 Frame layout (one frame = content of ONE QR code) +Wire format v2 (compact, current export): + ``` -BALQR1|||| +BAL1 ``` -- Magic+version literal `BALQR1` (reject anything else with a clear message; - keeps the door open for a future `BALQR2`). -- `` N, `` i — integers, `1 ≤ i ≤ N`. -- ``: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed). +- Magic+version literal `BAL1` (reject anything else with a clear message). +- `` = `` — **base36** zero-padded 3-char strings (`000`…`ZZZ`), + representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a + 3-digit count field costs the same for a 1-frame or a 46655-frame transfer. +- ``: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed. - ``: the i-th slice of `transfer_string`, exactly - `chunk_size` bytes each (last slice may be shorter). -- Header overhead ≈ 16–20 bytes → effective payload = `chunk_size − overhead`; - the chunker slices the transfer string so that **header+payload ≤ preset - size**. + `chunk_size` bytes each (last slice may be shorter). No separators: both + base36 count fields are fixed-width, so the header is unambiguously 11 + chars and the payload starts at offset 11. +- Header overhead is a constant **11 bytes** → effective payload = + `chunk_size − 11`; the chunker slices the transfer string so that + **header+payload ≤ preset size**. + +Legacy frames `BALQR1||||` (variable-width +decimal header, pipe-separated) are still **imported** by `parse_frame` +(`_parse_v1`), so old exports keep working; the exporter emits v2 only. +That is the one deliberate compatibility break: **v2 frames are not readable +by builds older than this change.** ### 4.3 Size presets (D5) @@ -173,12 +184,18 @@ def encode_transfer(tx_strings: list[str], compress: bool = False) -> str """Join -> optional zlib+base64 -> return transfer_string.""" def split_frames(transfer_string: str, chunk_size: int) -> list[str] - """Slice into full frames 'BALQR1|N|i|flags|payload'. Raises + """Slice into frames 'BAL1payload' (v2) — header+payload <= + chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or ValueError if chunk_size < MIN_CHUNK_SIZE.""" +def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool] + """-> (transfer_string, compressed); ships the shorter of plain vs + zlib+base64 so the export emits the densest frames.""" + def parse_frame(frame: str) -> tuple[int, int, bool, str] - """-> (total, index, compressed, payload); ValueError on bad magic/ - version/arity/non-int fields.""" + """-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy + 'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/ + version/arity/non-numeric fields.""" def assemble(frames: dict[int, str]) -> str """Validate indices form exactly range(1..max_total) (taken from any @@ -260,7 +277,7 @@ def get_audio_modem_plugin(self): # on BalWindow Layout: ``` -[Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)] +[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox) [ QR image ] ← BalQrImage (see below) «i di N» [◀ Prev] [Next ▶] [Save current QR as PNG…] [Send via Audio Modem…] [Close] @@ -268,7 +285,7 @@ Layout: Behaviour: -- On any control change: rebuild `split_frames(encode_transfer(...))`, +- On any control change: rebuild `split_frames(encode_transfer_best(...))`, reset index to frame 1, refresh counter (owner requirement: "cambiare la risoluzione"). - `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget` diff --git a/README.md b/README.md index 706499f..f7c8c58 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ exists, then enable it from **Tools → Plugins**. From the will list (**Export → QR Codes**) a will can be exported as a sequence of QR codes and imported on another device (**Import via QR**). The export offers All / Valid / Valid-NC filters plus a QR size preset -(150–1800 bytes/frame); the import flow reviews and sign each transaction +(150–1800 bytes/frame) and ships the default **BAL QR** format already +compressed whenever that is smaller (best-of zlib, flag per frame); the import +flow reviews and sign each transaction one at a time, then proposes exporting the signed transactions. When Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` + PortAudio) Send/Receive audio buttons complement the QR channel. See diff --git a/bal/core/animated_qr.py b/bal/core/animated_qr.py index 64b30cf..58cfd30 100644 --- a/bal/core/animated_qr.py +++ b/bal/core/animated_qr.py @@ -8,7 +8,9 @@ used to move BAL will data between devices. Supported wire formats (each self-describing and order-independent on receive): -* **BALQR** (native, unchanged): ``BALQR1|total|index|flags|payload``. +* **BALQR** (native): ``BAL1`` compact fixed-width + header (11 chars, 3-digit base36 count fields, no separators); legacy + ``BALQR1|total|index|flags|payload`` still imported. * **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020):: ur:bytes/1of7// Fragments partition the BC32 rendering of the CBOR byte string; the @@ -790,7 +792,7 @@ def detect_format(text: str) -> Optional[str]: if not text: return None lowered = text.lower() - if lowered.startswith("balqr"): + if lowered.startswith(("balqr", "bal1")): return "balqr" if text.startswith(_BBQR_PREFIX): return "bbqr" diff --git a/bal/core/qrtransfer.py b/bal/core/qrtransfer.py index 227e00a..6c7d4f2 100644 --- a/bal/core/qrtransfer.py +++ b/bal/core/qrtransfer.py @@ -10,9 +10,27 @@ Scope * converts will transactions into a compact ``transfer_string`` (newline-joined serialized transactions, optionally zlib + base64 compressed); -* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for +* splits that string into fixed-size ``BAL1`` frames for multi-QR export, and reassembles/validates them on import. +Wire format (v2, compact) +------------------------- +A frame is:: + + BAL1 + +* ``BAL1`` - magic + format era (4 chars). +* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655). +* ``iii`` - frame index as exactly 3 base36 digits (1-based). +* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII). +* ``payload`` - every other character of the frame; the payloads of all + frames, concatenated in index order, rebuild the transfer string. + +The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form +(same 5 pieces of information) without any pipe separator, so the whole +frame is scan-friendly and the overhead no longer grows with the frame +count. Legacy ``BALQR1|…`` frames are still accepted on import. + The audio-modem channel deliberately bypasses the framing helpers here (PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and carries the whole transfer string in a single blob, so callers only use @@ -29,6 +47,7 @@ import zlib MAGIC = "BALQR" VERSION = 1 FLAG_COMPRESSED = "Z" +FLAG_PLAIN = "0" # 4 standard presets (label, payload budget in bytes per QR). Ordered from # low-resolution cameras to high-resolution cameras (owner decision D5). @@ -43,7 +62,15 @@ CHUNK_PRESETS = ( # could consume the whole budget. MIN_CHUNK_SIZE = 40 -_FRAME_MAGIC = MAGIC + str(VERSION) +# Legacy wire format (still imported); the exporter emits the v2 form below. +_FRAME_MAGIC_V1 = MAGIC + str(VERSION) + +# Compact v2 wire format: fixed-width base36 count fields, no separators. +_FRAME_MAGIC_V2 = "BAL1" +_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +_BASE36_WIDTH = 3 +_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1 +_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1 class QrTransferError(ValueError): @@ -66,12 +93,26 @@ def encode_transfer(tx_strings, compress=False): """Join serialized transaction strings into a transfer string. ``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so - the whole bundle shrinks before being printed/scanned. The optional flags - of the frame header let the importer reverse this automatically. + the whole bundle shrinks before being printed/scanned. The optional flag + of the frame header lets the importer reverse this automatically. """ return __compress("\n".join(tx_strings), enabled=compress) +def encode_transfer_best(tx_strings): + """Encode ``tx_strings`` with the smaller of plain vs compressed form. + + Returns ``(transfer_string, compressed: bool)``. Compressed wins only + when zlib + base64 really is shorter (best-of, never larger). + """ + joined = "\n".join(tx_strings) + plain = joined + compressed = __compress(joined, enabled=True) + if len(compressed) < len(plain): + return compressed, True + return plain, False + + def decode_transfer(transfer_string, compressed): """Inverse of :func:`encode_transfer`. @@ -84,28 +125,33 @@ def decode_transfer(transfer_string, compressed): def split_frames(transfer_string, chunk_size, compressed=False): - """Split ``transfer_string`` into full ``BALQR`` frames. + """Split ``transfer_string`` into full compact ``BAL1`` frames. - Every returned frame is at most ``chunk_size`` characters long (header - included). ``compressed`` propagates the ``Z`` flag into every frame so - the importer knows how to reverse the encoding. + Every returned frame has the fixed 11-char v2 header followed by its + share of the payload, so each frame is at most ``chunk_size`` characters + long. ``compressed`` stamps the ``Z`` flag into every frame so the + importer knows how to reverse the encoding. Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold - the header plus any payload. + the header plus any payload, or when the transfer needs more than + :data:`_MAX_TOTAL` frames. """ - flags = FLAG_COMPRESSED if compressed else "" - total = __compute_total(len(transfer_string), chunk_size, flags) + flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN + total = __compute_total(len(transfer_string), chunk_size) + budget = chunk_size - _HEADER_V2_LEN frames = [] pos = 0 length = len(transfer_string) for index in range(1, total + 1): - overhead = len(__frame_header(total, index, flags)) - budget = chunk_size - overhead end = min(pos + budget, length) - frames.append(__build_frame(total, index, flags, transfer_string[pos:end])) + frames.append( + _FRAME_MAGIC_V2 + + _base36(total) + + _base36(index) + + flag + + transfer_string[pos:end] + ) pos = end - if pos >= length: - break if pos < length: # __compute_total guarantees this cannot happen; keep a safety net. raise QrTransferError("internal error: frames did not cover the transfer string") @@ -115,26 +161,16 @@ def split_frames(transfer_string, chunk_size, compressed=False): def parse_frame(frame): """Parse a single frame. + Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and + the compact ``BAL1`` v2 form. + Returns ``(total, index, compressed: bool, payload: str)``. Raises :class:`QrTransferError` on malformed input (bad magic/version, wrong arity, non-integer or out-of-range frame numbers, unknown flags). """ - parts = frame.split("|", maxsplit=4) - if len(parts) != 5: - raise QrTransferError("Not a BAL will QR (bad frame structure)") - magic_seen, total_s, index_s, flags, payload = parts - if magic_seen != _FRAME_MAGIC: - raise QrTransferError("Not a BAL will QR (unknown magic/version)") - try: - total = int(total_s) - index = int(index_s) - except ValueError as e: - raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e - if total < 1 or not 1 <= index <= total: - raise QrTransferError("Not a BAL will QR (frame numbering out of range)") - if flags not in ("", FLAG_COMPRESSED): - raise QrTransferError("Not a BAL will QR (unknown flags)") - return total, index, flags == FLAG_COMPRESSED, payload + if frame.startswith(_FRAME_MAGIC_V2): + return _parse_v2(frame) + return _parse_v1(frame) def assemble(frames, total): @@ -181,32 +217,88 @@ def __decompress(text, *, enabled): return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") -def __frame_header(total, index, flags): - return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags) +def _base36(n): + """Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``.""" + if not 0 <= n <= _MAX_TOTAL: + raise QrTransferError("BAL QR part number out of range: {}".format(n)) + chars = [] + for _ in range(_BASE36_WIDTH): + chars.append(_BASE36_DIGITS[n % 36]) + n //= 36 + return "".join(reversed(chars)) -def __build_frame(total, index, flags, payload): - return __frame_header(total, index, flags) + payload +def _base36_decode(text): + """Inverse of :func:`_base36`; raises ``ValueError`` on bad input.""" + if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text): + raise ValueError(text) + n = 0 + for c in text: + n = n * 36 + _BASE36_DIGITS.index(c) + return n -def __compute_total(transfer_len, chunk_size, flags): +def _parse_v1(frame): + parts = frame.split("|", maxsplit=4) + if len(parts) != 5: + raise QrTransferError("Not a BAL will QR (bad frame structure)") + magic_seen, total_s, index_s, flags, payload = parts + if magic_seen != _FRAME_MAGIC_V1: + raise QrTransferError("Not a BAL will QR (unknown magic/version)") + try: + total = int(total_s) + index = int(index_s) + except ValueError as e: + raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e + if total < 1 or not 1 <= index <= total: + raise QrTransferError("Not a BAL will QR (frame numbering out of range)") + if flags not in ("", FLAG_COMPRESSED): + raise QrTransferError("Not a BAL will QR (unknown flags)") + return total, index, flags == FLAG_COMPRESSED, payload + + +def _parse_v2(frame): + if len(frame) < _HEADER_V2_LEN: + raise QrTransferError("Not a BAL will QR (bad frame structure)") + # Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag + # make up the rest of the fixed header. + offset = len(_FRAME_MAGIC_V2) + total_s = frame[offset : offset + _BASE36_WIDTH] + index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH] + flag = frame[offset + 2 * _BASE36_WIDTH] + try: + total = _base36_decode(total_s) + index = _base36_decode(index_s) + except ValueError: + raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None + if total < 1 or not 1 <= index <= total: + raise QrTransferError("Not a BAL will QR (frame numbering out of range)") + if flag not in (FLAG_PLAIN, FLAG_COMPRESSED): + raise QrTransferError("Not a BAL will QR (unknown flags)") + payload = frame[_HEADER_V2_LEN:] + return total, index, flag == FLAG_COMPRESSED, payload + + +def __compute_total(transfer_len, chunk_size): """Smallest frame count whose budget covers the whole transfer string. - The budget shrinks as ``total`` gains digits (wider header), so the count - is recomputed iteratively until it converges. + The v2 header is fixed-width, so the budget is constant and the count is + a plain ceiling division, capped at :data:`_MAX_TOTAL`. """ if chunk_size < MIN_CHUNK_SIZE: raise QrTransferError( "chunk size too small to hold a BAL QR frame: {}".format(chunk_size) ) - total = 1 - while True: - overhead = len(__frame_header(total, total, flags)) - budget = chunk_size - overhead - if budget <= 0: - raise QrTransferError( - "chunk size too small for the BAL QR frame header: {}".format(chunk_size) - ) - if transfer_len <= budget * total: - return total - total += 1 + budget = chunk_size - _HEADER_V2_LEN + if budget <= 0: + raise QrTransferError( + "chunk size too small for the BAL QR frame header: {}".format(chunk_size) + ) + total = -(-transfer_len // budget) + if total < 1: + total = 1 + if total > _MAX_TOTAL: + raise QrTransferError( + "BAL QR transfer demands too many frames: {}".format(total) + ) + return total diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 84869f7..13f40cb 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -53,6 +53,7 @@ from ...core.qrtransfer import ( QrTransferError, decode_transfer, encode_transfer, + encode_transfer_best, preset_index_for_chunk_size, split_frames, ) @@ -2799,6 +2800,12 @@ class BalQrExportWidget(QWidget): self.tx_strings = list(tx_strings) self._stop_auto() self.transfer = encode_transfer(self.tx_strings, compress=False) + # BAL QR now ships compact (best-of) compressed by default: smaller + # frames, and the importer reverses it via the per-frame flag. The + # other formats keep the raw transfer (they compress internally). + self._balqr_transfer, self._balqr_compressed = encode_transfer_best( + self.tx_strings + ) self._refresh_frames() self._update_intro() self._render() @@ -2810,7 +2817,9 @@ class BalQrExportWidget(QWidget): def _refresh_frames(self): if self.format == "balqr": self.frames = split_frames( - self.transfer, self.chunk_size, compressed=False + self._balqr_transfer, + self.chunk_size, + compressed=self._balqr_compressed, ) else: self.frames = encode_animated_frames( diff --git a/tests/test_core_animated_qr.py b/tests/test_core_animated_qr.py index 509ab2d..25438d7 100644 --- a/tests/test_core_animated_qr.py +++ b/tests/test_core_animated_qr.py @@ -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]) diff --git a/tests/test_core_qr_transfer.py b/tests/test_core_qr_transfer.py index b7de47b..b52aca5 100644 --- a/tests/test_core_qr_transfer.py +++ b/tests/test_core_qr_transfer.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/tests/test_gui_qr_transfer.py b/tests/test_gui_qr_transfer.py index 226fb89..3d158ca 100644 --- a/tests/test_gui_qr_transfer.py +++ b/tests/test_gui_qr_transfer.py @@ -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