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:
62
CHANGELOG.md
62
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<TTT><iii><F><payload>` — 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.
|
||||
|
||||
@@ -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<total><index><flag>…` (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/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
||||
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
||||
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
||||
|
||||
@@ -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<total><index><flag>`, 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|<total>|<index>|<flags>|<payload>
|
||||
BAL1<TTT><iii><F><payload>
|
||||
```
|
||||
|
||||
- Magic+version literal `BALQR1` (reject anything else with a clear message;
|
||||
keeps the door open for a future `BALQR2`).
|
||||
- `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`.
|
||||
- `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed).
|
||||
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||
- `<TTT>` = `<iii>` — **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.
|
||||
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||
- `<payload>`: 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|<total>|<index>|<flags>|<payload>` (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 'BAL1<TTT><iii><F>payload' (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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TTT><iii><flag><payload>`` 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/<bc32-digest>/<bc32-fragment>
|
||||
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"
|
||||
|
||||
@@ -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<TTT><iii><flag>`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
|
||||
Wire format (v2, compact)
|
||||
-------------------------
|
||||
A frame is::
|
||||
|
||||
BAL1<TTT><iii><flag><payload>
|
||||
|
||||
* ``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<total><index><flag><payload>`` 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
|
||||
budget = chunk_size - _HEADER_V2_LEN
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
if transfer_len <= budget * total:
|
||||
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
|
||||
total += 1
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user