diff --git a/.gitignore b/.gitignore index 472a39c..e3b7884 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ tmp* # Release artifacts bal_v*.zip.* +tests/karen7 diff --git a/.opencode/plans/plan_android_reader.md b/.opencode/plans/plan_android_reader.md new file mode 100644 index 0000000..1f2ffc6 --- /dev/null +++ b/.opencode/plans/plan_android_reader.md @@ -0,0 +1,231 @@ +# PLAN_ANDROID_READER.md — BAL Reader: an Android QR will reader + +**Date:** 2026-09-09 · **Status:** proposed · **Ties into:** BAL plugin QR export (BALQR / BC-UR v1 / BC-UR v2 / BBQR) + +## 1. Goal + +Ship a minimal single-activity Android app that reads any QR will exported by the BAL +Electrum plugin, decodes all four supported wire formats, and lets the user view, copy, +share, or save the recovered will data. The app is a **reader only** — it never signs or +broadcasts. + +## 2. Scope + +**In scope** + +- Continuous camera capture (CameraX + ML Kit on-device barcode scanning, QR-only). +- Auto-detection of the four formats (`detect_format`: `"balqr" / "ur1" / "ur2" / "bbqr"`). +- Order-independent frame assembly: duplicates ignored, out-of-order accepted, UR v2 + XOR-fountain redundancy exploited, session-level reset on transfer switch. +- Whole-will JSON **and** tx-hex-list payloads, both decoded to a readable view. +- Copy raw transfer text / Share / Save via Storage Access Framework. +- Verified decode chain runnable on the dev machine (no Android needed). + +**Out of scope** + +- Signing, broadcasting, password handling, wallet integration. +- Export/authoring QR codes *from* the phone. +- Audio-modem transport (unchanged, plugin-only). + +## 3. Architecture + +### 3.1 Why Chaquopy (reuse the tested Python codecs) + +`bal/core/animated_qr.py` (1178 lines) and `bal/core/qrtransfer.py` (212 lines) are +**pure stdlib** (`base64`, `hashlib`, `zlib`), GUI-free, Python-3.8-clean (verified: no +walrus/match/`X|Y`/PEP-585 generics), and `bal/core/__init__.py` is an empty docstring. +Chaquopy bundles CPython into the APK, so **the exact, battle-tested codec modules run +unchanged on Android** with zero port risk. The Kotlin side is only camera glue + UI. + +### 3.2 Decode chain (mirror of the plugin's import tail) + +The plugin's `_review_and_sign` (dialogs.py:3792) does exactly: + +1. `session.resolve()` -> `(transfer_text, compressed: bool)` + (UR v1/v2/BBQR for the first pair; `(text, flag)` for BAL QR). +2. `decode_transfer(transfer_text, compressed)` -> list of parts (tx hexes, or the single + whole-will JSON). +3. `"\n".join(parts)` -> opaque payload. +4. `decode_will_payload(payload)` -> `("will", dict)` **or** `("txs", [strings])`. + +The app reuses all of it verbatim through the `AnimatedQrSession` facade (`add_part` +auto-detects per frame, dedups, tracks `received/total/done`). + +### 3.3 Data flow + +``` +CameraX ImageAnalysis -> ML Kit BarcodeScanning (QR) -> raw string + -> BalDecoder.add(text) [Chaquopy -> AnimatedQrSession.add_part] + -> status: format chip + received/total, duplicate-tolerant + -> when done: auto-stop -> BalDecoder.finish() (4-step decode) + -> ResultActivity: JSON view OR tx-list view + Copy/Share/Save +``` + +## 4. Repository layout (new `android/` subfolder) + +``` +android/ + README.md # build (Android Studio), usage, codec-sync rule, MIT note + settings.gradle.kts + build.gradle.kts # root: plugins (AGP 8.10, Kotlin 2.0.21, Chaquopy 17, apply false) + gradle.properties + gradlew, gradlew.bat + gradle/wrapper/ # gradle-wrapper.properties + gradle-wrapper.jar (fetched; see §7) + scripts/ + sync_codecs.py # copy bal/core/{__init__,animated_qr,qrtransfer}.py -> app python dir; import-check + test_chain/ + verify_chain.py # full decode-chain simulation, runs on dev machine + app/ + build.gradle.kts # com.android.application + com.chaquo.python; CameraX/ML Kit deps + src/main/ + AndroidManifest.xml # CAMERA permission + java/life/after/bitcoin/ + MainActivity.kt # permission + PreviewView + ImageAnalysis + status header + BalDecoder.kt # Chaquopy bridge (§5.1) + ResultActivity.kt # viewer + copy/share/save (§5.3) + res/layout/activity_main.xml, activity_result.xml + res/values/strings.xml + python/bal/core/ # SYNCED COPIES (committed, deterministic; regenerate with script) + __init__.py + animated_qr.py + qrtransfer.py +``` + +## 5. Component specifications + +### 5.1 `BalDecoder` (Kotlin, Chaquopy bridge) + +- Lazy init: `Python.start(AndroidPlatform(context))`, `getModule("bal.core.animated_qr")`. +- `fun add(text: String): String` -> `session.add_part(text)`; surfaces `"ok"`/`"dup"`; + maps `AnimatedQrError` subclasses to a result the UI can ignore (garbage frames) vs + reset (transfer switch -> `TransferConflictError` -> tell user to rescan). +- `val format: String?` (`"balqr"/"ur1"/"ur2"/"bbqr"`), `val received: Int`, + `val total: Int`, `val done: Boolean` (auto-converted by Chaquopy). +- `fun finish(): DecodedResult` - the 4-step chain; returns + `data class DecodedResult(kind: "will"|"txs", data: Map|List, rawTransfer: String)`. +- `fun reset()` -> new `AnimatedQrSession` (new scan). + +### 5.2 `MainActivity` (camera + scan loop) + +- Launches CameraX via `ProcessCameraProvider`; `PreviewView` fills screen; camera + permission via `ActivityResultContracts.RequestPermission`. +- `ImageAnalysis` `STRATEGY_KEEP_ONLY_LATEST`; analyzer throttled (~100 ms) calls ML Kit + `BarcodeScanning` with `Barcode.FORMAT_QR_CODE`. +- Thread-safe feed to `BalDecoder` (analyzer runs on a background executor); UI status + via `runOnUiThread`. +- Header row: format chip + `received/total`; on `done` -> stop analyzer -> launch + `ResultActivity` (results as Parcelable); "New scan" restarts. +- Garbage / incomplete frames silently ignored (same policy as the plugin); a + `TransferConflictError` mid-scan resets the session and signals the user to rescan. + +### 5.3 `ResultActivity` (viewer) + +- `kind == "will"`: whole-will JSON - each item's `tx` hex shown truncated with full view + on demand. +- `kind == "txs"`: list of tx hexes. +- Action bar: **Copy** (raw transfer text to clipboard), **Share** (ACTION_SEND + text/plain), **Save** (SAF `ACTION_CREATE_DOCUMENT` -> `will.json` / `will_tx.txt`). +- "Scan another" button -> finish -> back to camera. + +### 5.4 `scripts/sync_codecs.py` + +- Copies the 3 files from `bal/core/` -> `app/src/main/python/bal/core/` (idempotent). +- Post-copy check (dev machine): import `bal.core.animated_qr`, run one UR v2 frame + + decode cycle to prove the copy imports standalone. +- Documented as the rule after any codec change (README). + +### 5.5 `test_chain/verify_chain.py` + +Simulates the exact APK runtime path on the dev machine (no Android). Generates frames +precisely as the export page does, then feeds `AnimatedQrSession.add_part` in +scrambled/duplicated/dropped order and asserts correct results for: + +- BAL QR multi-frame, plain **and** compressed (`Z` flag): `split_frames(encode_transfer(...))`. +- UR v1 single-part (`ur1_frames` headerless) and `1ofN` multipart. +- UR v2 single-part and fountain multipart with >=1 frame dropped and >=1 duplicated + (exercises the XOR solve). +- BBQR `Z`/`H`/`2`: `bbqr_frames(..., encoding=...)`, with the `Z` auto-decompress branch. +- payload kinds: whole-will JSON **and** tx-hex list. +- transfer-switch: feed a frame of a different transfer mid-session -> expect conflict, as + the UI will. + +Runs under the runtime venv: +`source .../electrum/env/bin/activate && QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` + +## 6. Wire-format reference (bundled codecs) + +- **BAL QR**: `BALQR1||||`, flags `""` or `Z` (zlib+base64). + Concatenate payloads 1..total -> transfer string -> `decode_transfer` splits on `\n`. +- **BC-UR v1**: multipart `ur:bytes/of//`; + single-part `ur:bytes/` (digest-less). BC32 = bech32_bis (XOR `0x3FFFFFFF`) + 5-bit alphabet. +- **BC-UR v2**: multipart `ur:bytes/-of-/` + + single-part headerless; part body = CBOR `[seq, seq_len, msg_len, crc32, data]` + + per-part CRC-32, bytewords-minimal; fountain via `choose_fragments` (xoshiro256** + + alias/threshold), mixed by XOR - decoder solves from any sufficient subset. +- **BBQR**: `B$`; encodings `H` + (upper hex), `2` (base32nal), `Z` (deflate `wbits=-10` -> base32). + +## 7. Toolchain & versions + +| Item | Version | Notes | +|---|---|---| +| Chaquopy | 17.0.0 | Python 3.10-3.14, AGP 7.3-9.2, minSdk 24 | +| AGP | 8.10.0 | in Chaquopy 17 range | +| Gradle wrapper | 8.14 | required by AGP 8.10 | +| Kotlin | 2.0.21 | | +| compile/target SDK | 35 / min 24 | | +| JDK | 17 (machine has OpenJDK 17) | | +| CameraX | 1.3.4 | `camera-core`, `camera-camera2`, `camera-lifecycle`, `camera-view` | +| ML Kit barcode | 17.3.0 | on-device, no API key | +| Python (codec) | 3.12 (Chaquopy) | codecs verified 3.8-clean | + +Dev machine: Android Studio + JDK 17 present; SDK platforms/build-tools/gradle absent -> +the **first real APK build happens in Android Studio with network**. `gradle-wrapper.jar` +is fetched via `curl` (canonical location) so `./gradlew` works; if network is blocked, +README documents Android Studio regenerating it. + +## 8. Build & run outline (for README) + +1. Open `android/` in Android Studio (or `./gradlew assembleDebug`). +2. Allow Gradle to fetch wrapper/deps (network). +3. Install on device; grant camera permission. +4. In the plugin: export dialog -> pick format (start with BAL QR default; also test + UR v1/UR v2/BBQR) -> show the animated QR on screen. +5. Point camera at screen; watch `received/total`; decoded result appears -> + Copy/Share/Save. + +## 9. Verification + +**On this machine (no Android needed)** + +1. `python3 android/scripts/sync_codecs.py` -> copy + import/roundtrip sanity. +2. `QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` -> all + formats/payload kinds/conflict cases pass. +3. `ruff check android/scripts/sync_codecs.py android/test_chain/verify_chain.py` -> clean. +4. `pytest tests/test_core_*.py tests/test_gui_*.py` -> still **445 passed** + (payload code untouched; only new files). +5. `python3 build_zip.py` unaffected (no change under `bal/`). + +**On-device (manual, user)** + +- Walk the 4 formats against the plugin's export page, single- and multi-frame + (animated), on a real phone. +- Confirm format chip, progress, auto-finish, Copy/Share/Save. + +**Boundary** - no APK is produced by this machine's environment; the artifact is a +complete, independently buildable source tree + verified codec path. + +## 10. Risks & notes + +- First Gradle sync needs network (deps + wrapper). Pinned versions are conservative; + knobs documented. +- Bundled codec copies must be regenerated after any `bal/core/animated_qr.py` / + `qrtransfer.py` change - handled by `sync_codecs.py` + README note (copies are + committed for deterministic builds). +- Animated QR reading depends on the camera catching enough distinct frames (ML Kit + analyzer keeps scanning; the session dedups and accepts out-of-order). Slow phone + screens / glare may raise time-to-complete - expected, same as the plugin. +- App name/package (`life.after.bitcoin`, label "BAL Reader") are placeholders - trivial + to change. +- MIT: bundled codec files inherit the plugin's MIT license (noted in README). \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index f53b660..7d6146f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,9 @@ BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch). Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`. +Paths below are relative to the repo, or use `$BAL_HOME` (the directory +containing this repo and the sibling `electrum/` checkout). + ## Environments (critical) Two separate venvs; using the wrong one is the #1 mistake. diff --git a/AUDIO_MODEM_DEBIAN.md b/AUDIO_MODEM_DEBIAN.md new file mode 100644 index 0000000..7ec3e81 --- /dev/null +++ b/AUDIO_MODEM_DEBIAN.md @@ -0,0 +1,170 @@ +# Audio MODEM on Debian — setup & troubleshooting + +How to make the optional **audio channel** of BAL (and Electrum's own +`audio_modem` plugin) work on Debian/Ubuntu. The channel lets you send a will +to another device as acoustic OFDM tones instead of scanning QR codes. + +Recommended reading before starting: `CHANGELOG.md` entry 56 +(Audio-environment notes) and `HANDOFF.md` (Dev-box audio prerequisites). + +--- + +## 1. What you need (three independent pieces) + +| Piece | Provides | Where it comes from | +|--------------------------|--------------------------------------------|--------------------------------------| +| `amodem` (Python) | OFDM modulation/demodulation | `pip install amodem` (any venv) | +| `libportaudio.so` | sound I/O backend used by `amodem.audio` | Debian package `libportaudio2` (+ dev symlink, see §2) | +| Electrum `audio_modem` | the plugin whose `_send`/`_recv` BAL reuses | built into Electrum | + +BAL shows the audio buttons only when the plugin is **enabled** (Tools → +Plugins → Audio Modem) and `amodem` is importable. + +> On a headless/CI box there is no speaker/mic, but the channel can still be +> verified with the **sink-monitor loopback** in §4. + +--- + +## 2. The two line fixes (this is the part everyone forgets) + +Debian ships a versioned `libportaudio.so.2` but **not** the unversioned +`libportaudio.so` that old `amodem` code uses, and `amodem` uses NumPy APIs +removed in NumPy 2.x. Both fail **silently** (the plugin's `_send` runs the +load inside a `WaitingDialog` thread without an `on_error` handler). + +### 2a. PortAudio unversioned symlink + +Install the dev package (creates the unversioned symlink), or create it by +hand: + +```bash +sudo apt install libportaudio2 libportaudio-dev # preferred +# or, without the package: +sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 \ + /usr/lib/x86_64-linux-gnu/libportaudio.so +``` + +Verify: + +```bash +source "$BAL_HOME/electrum/env/bin/activate" +python3 -c "import amodem.audio; print(amodem.audio.Interface(config=None).load('libportaudio.so').call('GetVersionText'))" +# b'PortAudio V19...' <-- success +``` + +> **No-sudo alternative** (fine for one-shot tests): point `LD_LIBRARY_PATH` +> at a directory containing a `libportaudio.so` symlink to the `.so.2`: +> ```bash +> mkdir -p /tmp/portaudio_stub +> ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so +> export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH +> ``` + +### 2b. amodem vs NumPy 2.x (`tostring` removed) + +`amodem` 1.16.0 calls `numpy.ndarray.tostring()`, removed in NumPy 2.x +(≥ 2.4.6 dies with `AttributeError` on the first sample write, so **no carrier +is ever emitted**). Either pin NumPy < 2, or patch the single line in the +installed package: + +```bash +source "$BAL_HOME/electrum/env/bin/activate" +python3 -m pip install "numpy<2" # option A (downgrade) +# option B (patch; path depends on your site-packages): +sed -i "s/sym.astype('int16').tostring()/sym.astype('int16').tobytes()/" \ + "$BAL_HOME/electrum/env/lib/python3.11/site-packages/amodem/common.py" +``` + +> This must be done on **every** machine that receives/sends audio (both ends +> of the channel use the same code), and again after reinstalling/upgrading +> `amodem`. + +--- + +## 3. Environment checklist (dev box, already applied) + +These were applied on the current dev box and do NOT need to be re-done: + +- `amodem` installed in the runtime venv (`1.16.0`). +- `amodem/common.py` patched `tostring()` → `tobytes()`. +- System symlink or `LD_LIBRARY_PATH` stub for `libportaudio.so`. +- PulseAudio running; default sink `ALC236 Analog`, default source DMIC. + +Check them in one command: + +```bash +source "$BAL_HOME/electrum/env/bin/activate" +python3 - <<'EOF' +import amodem, ctypes, numpy, zlib +print("amodem", amodem.__version__) +print("numpy", numpy.__version__, "(2.x needs the tobytes patch)") +import amodem.audio +amodem.audio.Interface(config=None).load("libportaudio.so") +print("libportaudio.so loaded OK (symlink or LD_LIBRARY_PATH in place)") +EOF +``` + +--- + +## 4. Verifying the channel (no speakers/mic needed) + +Full **send → sink → sink-monitor → recv** round-trip on one machine: + +```bash +# 1) route capture at the loop and remember the original source +MON="$(pactl get-default-sink).monitor"; ORIG=$(pactl get-default-source) +pactl set-default-source "$MON" + +# 2) run the round-trip (uses zlib-compressed payload like the plugin) +source "$BAL_HOME/electrum/env/bin/activate" +timeout 90 python3 /tmp/opencode/bal_audio_loopback.py +# expected: bitrate 1.0 kbps ... send done ... RECV OK + +# 3) restore the original source +pactl set-default-source "$ORIG" +``` + +Any payload you like: `python3 /tmp/opencode/bal_audio_loopback.py "BALQR|1|1|0|hi"`. + +With real speakers + mic instead, skip the `pactl` swapping, put the devices +close, keep volumes high, and run the same script. + +--- + +## 5. Testing through the real GUI + +1. **Tools → (Plugins) → Audio Modem** → enable it. If asked for settings, + pick a bitrate: default `slowest()` is ~1.0–1.2 kbps (a ~2 KB will takes + ~15–20 s of audio); higher bitrates are faster but less robust. +2. Wallet A → BAL will list → **Export → QR Codes → Audio…** + (the audio transport sends the raw newline-joined tx list, no BAL framing). +3. Wallet B → will list → **Import via QR → Audio…** → wait for + "Waiting for audio (... kbps)…", a loading cursor while demodulating, + then the decoded slots appear → review/sign wizard opens. +4. One machine only: apply the §4 monitor trick in the shell where Electrum + runs (export plays to the sink; import records from the sink monitor). + +--- + +## 6. Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub | +| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer | +| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines | +| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` | +| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog | + +--- + +## 7. No-sudo quick reference (all commands) + +```bash +python3 -m pip install amodem +mkdir -p /tmp/portaudio_stub +ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so +export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH +# numpy >= 2 (one of): +pip install "numpy<2" # or patch amodem/common.py tobytes +``` \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a45776b..1576e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2909,6 +2909,108 @@ renumber the grid rows. **Outcome:** DONE. +## 56. QR / audio will transfer (chunked multi-QR export/import + review-and-sign wizard) + +**Date:** 2026-08-27 + +**Goal (owner request):** export a will to another device via QR codes (with +an optional audio channel on top), and import it there with a per-transaction +review-and-sign flow. QR codes are chunked because a full will usually exceeds +a single code's capacity. + +**What changed:** + +- `bal/core/qrtransfer.py` (new, GUI-free): the wire format and chunk + scheduler — `BALQR{version}|{total}|{index}|{flags}|{payload}` frames, + `encode_transfer` / `decode_transfer`, `split_frames` / `parse_frame` / + `assemble`, optional `Z` (zlib+base64) compression at export, four chunk + presets (150/400/900/1800 bytes/frame, EC level M), plus + `preset_index_for_chunk_size` and the `QrTransferError` exception family. +- `tests/test_core_qr_transfer.py` (new): round-trips (plain/compressed), + boundaries (exact-fit, size>payload, `|` in payload), min-size guard, bad + magic/version/numbers/flags, multi-frame reassembly, header consistency. +- `bal/core/plugin_base.py`: new `QR_CHUNK_SIZE` config (default 150). +- `bal/gui/qt/plugin.py`: settings-dialog row 16 "QR Code Size" combo + (4 presets) + reset button, visible in BASIC and ADVANCED. +- `bal/gui/qt/dialogs.py`: + - `BalQrImage`: QR widget with MEDIUM error correction (Electrum's + `QRCodeWidget` is EC-L), reusing `draw_qr`. + - `WillQrExportDialog`: walks the frames (Prev/Next, "i of N" progress), + export filters **All / Valid / Valid-NC**, live chunk-preset selector, + **Auto slideshow** (toggle button + "QR codes per second" spinbox, stops on + the last frame and on filter/chunk changes), optional audio-send button. + - `WillQrImportDialog`: camera scan (Electrum `scan_qrcode_from_camera`, + one code at a time), manual paste fallback, slot grid (1..N) with + green=stored, total-mismatch reset, optional audio-receive that mirrors + the audio_modem `_recv` sink with a callback instead of `setText`. + - `WillTxReviewSignDialog`: per-transaction review (outputs via + `get_ui_address_str`, total outputs, fee) with Sign / Skip / Cancel and a + single wallet password; final page offers "Save signed file…" and + "Show signed QR…". Runs on the imported local copy only. +- `bal/gui/qt/window.py`: `export_will_via_qr`, `import_will_via_qr`, + `get_audio_modem_plugin`, `_audio_send_payload`; `sign_transactions` + refactored into a byte-equivalent batch loop plus the reusable + `_prepare_and_sign_tx(…, txid, password)` single-transaction helper. +- `bal/gui/qt/lists.py`: will-list menu gains **Export → QR Codes** and + **Import via QR**. +- `tests/test_gui_qr_transfer.py` (new): export build/navigation/chunk + change, filters (Valid, Valid-NC, empty-revert), import frame flow, + assembly+decode, total-mismatch reset, manual entry. +- `PLAN_QR_TRANSFER.md`: the full spec (wire format, settings, export, + import, wizard checklist P0–P6, findings log). +- Docs: `README.md` QR-transfer section; `QML_PLAN.md` updated (Phase 2 + `BalQrTransferModel`, Phase 3 dedicated QML export/import pages, R6 + mitigation rewritten, deferred-chunks note removed). + +**Audio channel caveats:** +- The audio send/receive buttons only appear when Electrum's `audio_modem` + plugin is enabled *and* `amodem` + PortAudio are installed (not present in + the current dev runtime — verified F22). On that channel the transport + zlib-compresses internally, so no BAL framing/`Z` flag is used. +- `WaitingDialog` requires a real `QWidget` parent and the plugin's `_recv` + hard-wires `parent.setText`, so receive uses a local mirror with a + callback sink. + +**Verification:** +- `python3 tests/test_core_qr_transfer.py`: all pass. +- `QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py`: all pass. +- Pytest batch `tests/test_core_*.py tests/test_gui_*.py`: 374 passed (only + the two pre-existing `test_bt_to_date_*` datetime compare failures remain). +- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py + electrum.plugins.bal`: passed (clean import under real Electrum). +- `ruff`: no new violations on changed files. + +**Audio-environment notes (dev box, discovered while testing):** +- `amodem` 1.16.0 is old and uses `np.ndarray.tostring()`, removed in numpy 2.x; + the runtime venv (numpy 2.4.6) needs the one-line patch + `tostring()` → `tobytes()` in + `electrum/env/lib/python3.11/site-packages/amodem/common.py` (done locally, + not in the repo). Any machine with numpy>=2 and this amodem version needs + the same patch (or numpy<2). +- Electrum's `audio_modem` plugin hardcodes `libportaudio.so` (unversioned). + Debian/Ubuntu only ship `libportaudio.so.2`, so the load fails silently + inside the plugin's `_send` `WaitingDialog` (no `on_error` → no sound, no + message). Fix on the dev box: + `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so` + (created by the `libportaudio-dev` package; a `LD_LIBRARY_PATH` stub works + without root). The audio buttons stay hidden unless the plugin is enabled + and available. +- Verified on the dev box (no physical mic required) via a full + send→sink→monitor→recv round-trip: set the default PulseAudio source to + `.monitor` at receive time; payload returned byte-identical. + +**Follow-up fixes (same session, reported during audio testing):** +- `WillItem.__init__` now defaults `status` to `""` instead of `None`. A + `WillItem` built from a bare `{"tx": ...}` (QR/audio import, clipboard + merge) crashed in `set_status` with + `unsupported operand type(s) for +=: 'NoneType' and 'str'` during the + validity pass / `IMPORTED` marking. +- `BalWindow.invalidate_will` guards a missing `date_to_check` (first-action + case) like `merge_will` already did, fixing + `AttributeError: 'BalWindow' object has no attribute 'date_to_check'` when + invalidating before the periodic check initialized it. +- Regression test `test_imported_item_status_not_none` added to + `tests/test_gui_qr_transfer.py`; QR GUI suite 12/12, batch 377 passed. --- ## 57. Name the real cause of a failed build instead of guessing @@ -2992,4 +3094,236 @@ misleading. - Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`, `WILLEXECUTOR_FEE` and `No Heirs` were all confirmed on screen. +## 57. Remove all `copy.deepcopy` (ad-hoc copy helpers; `WillItem` copies serialize/deserialize) + +**Date:** 2026-08-28 + +**Goal (owner request):** eliminate every `copy.deepcopy` from the codebase +and replace it with ad-hoc copy methods; `WillItem` copies must be produced by +serializing and deserializing the item rather than by deep-copying live +runtime objects (which can hold a `threading.RLock` and cannot be pickled). + +**What changed:** + +- `bal/core/util.py`: new `copy_structure(value, _path="copy")` — the single + JSON-safe, deepcopy-free recursive cloner (dict / list / tuple cloned + structurally, JSON scalars kept as-is, any accidental runtime object coerced + to `str` + logged). It replaces the old `heirs._json_safe` implementation. +- `bal/core/heirs.py`: `_json_safe` is now a thin backward-compatible alias of + `bal.core.util.copy_structure`; `Heirs.save` behaviour is unchanged. +- `bal/core/will.py`: + - `WillItem.__init__` on a `WillItem` argument no longer does + `self.__dict__ = w.__dict__.copy()` + `copy.deepcopy`; instead it + serializes (`to_dict()`) and deserializes: the tx is re-parsed into a fresh + object, `STATUS` is rebuilt from a clone, and heirs / will-executors are + cloned recursively, so the copy shares no mutable state with the source. + - New `WillItem.copy(wallet=None)` (serialize/deserialize round trip; re-adds + wallet tx info when a wallet is passed) and the static + `WillItem.copy_status_table(table)` used for the `STATUS` tables. + - `to_dict()` now also emits `Father` / `Children` so the round trip is + faithful. + - `normalize_will` routes copies through the constructor / `copy()`. +- `bal/gui/qt/window.py` and `bal/cli/controller.py`: the Build-will flow now + uses `copy_structure(...)` instead of `copy.deepcopy(...)` for heirs and + will-executors. +- Dropped now-unused `import copy` (`will.py`, `controller.py`, `qt/common.py`, + `qt/window.py`). +- Tests updated to the same helpers: STATUS tables via + `WillItem.copy_status_table`, heirs / built dicts via `copy_structure` + (`test_core_will.py`, `test_core_will_invalidate.py`, + `test_heir_relative_anchor.py`, `test_anticipate_manual_locktime.py`, + `test_no_willexecutor_karen7.py`, `test_reproduce_none_type.py`, + `test_group_e_mock_karen7.py`, `test_group_e_karen7_invalidate.py`, + `sim_update_flows.py`). + +**Verification:** +- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 377 passed, + only the two pre-existing `test_bt_to_date_*` datetime compare failures + remain (identical to HEAD — no regression; `test_heir_relative_anchor` + isolated-file failure is pre-existing test-pollution at HEAD too). +- Ad-hoc semantics check: `copy()`/ctor copy share no mutable state with the + source (mutating source heirs/STATUS does not leak into the copy and vice + versa), `copy_status_table` returns fresh lists, `normalize_will` runs. +- `ruff` on all touched files: no new violations (4 findings, all pre-existing + at HEAD). +- `tests/smoke_test.py electrum.plugins.bal`: passed. +- `python3 build_zip.py`: 45 files, 343591 bytes, sha256 `aa8f8154…`; + `tests/external_zip_test.py bal-electrum-plugin.zip`: passed (Plugin class + loads via the zipimport shim). + +**Outcome:** DONE. + +--- + +## Next. Animated-QR interop (BC-UR v1/v2, BBQR) + +**Date:** 2026-09-08 + +**Goal:** Let BAL export/import a will not only as its own BAL QR frame format +but also as BC-UR v1 (`ur:bytes`, BC32 + SHA-256), BC-UR v2 (`ur:bytes`, CBOR +bytewords-minimal fountain codes) and BBQR (Coinkite `B$…`) animated-QR +sequences, so transfers interoperate with Blockchain Commons / Coldcard-style +tools and BitKit. Codecs must be stdlib-only and the export must keep BAL QR +as the default. + +**What changed:** + +- `bal/core/animated_qr.py` (new): stdlib-only codec module. + - BC32 (bech32_bis checksum, XOR `0x3FFFFFFF`) encode/decode matching the + BCR-2020-004/005 reference vectors. + - bytewords-minimal encode/decode (BCR-2020-012) with CRC-32 rejection; + the word list was transcribed verbatim from the reference C++. + - BC-UR v2: CBOR part writer/reader, CRC-32, `choose_fragments` + (xoshiro256** + alias + ary-threshold sampler) and XOR-based fountain + mixing/solving; emits a redundant mixed wave for loss tolerance. + - BC-UR v1: multipart with SHA-256 digest and single-part digest-less + frames; `1of1` handling. + - BBQR: base32 (encoding `2`), hex (uppercase, `H`) and zlib (lowercase, + `Z`, automatic compression fallback) frames; out-of-order reconstruction. + - One `AnimatedQrSession` + `detect_format` + `parse_for_detection` for + auto-detecting the incoming format and keying the GUI debounce. + - Safety caps: `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`, + zlib-bomb guard, `TransferConflictError`/`SessionLimitError`. +- `bal/gui/qt/dialogs.py`: + - Export page (`BalQrExportWidget`) gained a **Format** selector + (BAL QR default, BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets, + with per-format intro/format-hint text. + - Import page (`BalQrImportWidget`) now routes every frame through + `parse_for_detection` + `AnimatedQrSession.add_part`, auto-detecting the + format and resetting when the transfer's session key changes; the + review/sign step resolves the session and decodes parts uniformly. + - `qr_import_accept_frame` generalised to + `(state, fmt, session_key, frame_total, index, payload, stable_reads=2)`. +- `tests/test_core_animated_qr.py` (new, 32 tests): BC32 spec vectors, + bytewords round-trip/CRC, C++ reference-frame decode+re-encode parity + (single-part 12B, seq_len=2, seq_len=7), fountain solve with missing pure + part, out-of-order/duplicate handling, single/multipart UR v1, BBQR + Z/2/H round-trips, runt last part, zlib-bomb guard, detection positive/ + negative. + +**Verification:** + +- `tests/test_core_animated_qr.py`: 32/32 pass. +- `tests/test_gui_qr_transfer.py` (now 36 tests) + `test_gui_export_dialogs.py`: pass. +- `ruff` clean on `animated_qr.py`, `dialogs.py` and both test files; + `pyright` 0 errors on the touched modules. +- `tests/smoke_test.py electrum.plugins.bal`, `python3 build_zip.py` and + `external_zip_test.py` all pass. +- Full regression: 462 passed; only pre-existing failures remain + (`test_bt_to_date_*`, will-invalidate fee, unrelated `sign_transactions` + stub test). + +**Notes / caveats:** + +- A real bug was found & fixed during this work: `_ur2_part_cost` used + `2 * body_len` but `bytewords_minimal_encode` appends a 4-byte CRC, so every + UR v2 frame was undercounted by 8 characters and could overflow the QR + budget for large transfers. +- UR v1 multipart emits the digest-carrying `1of1//` form for a + single part (both headered and headerless single parts are accepted on + import); this keeps deterministic digest verification. +- Imported payloads are UTF-8 text; the codec sessions do not decode raw + binary transfer blobs. + +**Outcome:** DONE (uncommitted). + +--- + +## Animated-QR bugfix: QVideoSink signal wiring + will-export JSON crash + +**Date:** 2026-09-08 + +**Goal:** Fix two runtime crashes found by manual testing of the QR paths. + +**What changed:** + +- `bal/gui/qt/dialogs.py`: + - `_start_scan`/`_stop_scan` used `QVideoSink.videoFrame.connect/.disconnect`, + but on PyQt6 `videoFrame` is the frame **getter method**, not a signal — + this raised ``AttributeError: 'builtin_function_or_method' object has no + attribute 'connect'`` on camera scan. Switched to the `videoFrameChanged` + signal (same wiring Electrum's `QrReaderVideoSurface` uses). + - `_stop_scan` now tolerates `AttributeError` when disconnecting the sink + and guards the `errorOccurred` disconnect too, so a mid-init failure can + never cascade into a second uncaught exception. + - `_whole_will_json` (whole-will QR export) serialized ``WillItem.to_dict()`` + with plain `json.dumps`, crashing with ``TypeError: Object of type + Transaction is not JSON serializable`` (the ``tx`` field holds a real + ``Transaction``). Now uses Electrum's `MyEncoder`, matching `write_json_file`. +- `tests/test_gui_qr_transfer.py`: new `test_import_start_stop_scan_signal_wiring` + drives the real `QVideoSink` life-cycle with a mocked camera and fails if + the signal name regresses to `videoFrame`. +- `tests/test_gui_export_dialogs.py`: new + `test_qr_whole_will_json_serializes_transaction` covers the JSON export. + +**Verification:** + +- `pytest tests/test_gui_qr_transfer.py tests/test_gui_export_dialogs.py -q`: pass. +- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 444 passed. +- Regression test flips correctly (fails when reverted to the buggy call). +- `ruff` clean on touched files; `tests/smoke_test.py`, `build_zip.py`, + `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 8606368..a42079d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -23,6 +23,25 @@ is updated to mark them as supported. See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2 and 4.8.0). +## QR wire-format compatibility + +BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's +own frame format and is only understood by BAL itself. The export page also +supports **BC-UR v1**, **BC-UR v2** and **BBQR**: + +| Format | Wire appearance | Interop target | +|-----------|----------------------------|------------------------------------------------------| +| 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 | + +Import auto-detects the format of each scanned code; out-of-order, duplicate +and (for UR v2) partially-lost fountain frames are handled. Interop is +validation-tested against the reference C++ bc-ur encoder output and the +BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against +third-party libraries (`ur`, `bbqr`, Coldcard firmwares). + ## Reporting compatibility issues If you find a compatibility problem not listed here, please open an issue on diff --git a/HANDOFF.md b/HANDOFF.md index 159ca63..60b0177 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -111,7 +111,8 @@ Two separate venvs — using the wrong one is the #1 mistake: - **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot import `electrum` or `PyQt6`. Do NOT use it to run tests. -Run everything from the repo root. +Run everything from the repo root (the checkout directory; set +`BAL_HOME` to its parent to use `$BAL_HOME/electrum`). **Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its `test_*` functions from `if __name__ == "__main__"`. Run a file directly: @@ -392,3 +393,85 @@ See Section 5 for details. push to `origin/main`, then run `./make-release.sh` to create the Gitea **Release** with the ZIP + signatures attached (it becomes the owner's "Latest" download). Always give the owner the Release URL. + +### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`) + +- The full QR-transfer feature (P0–P6) is implemented, tested and committed on + `feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to + `origin`). PR creation URL: + `https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer` +- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE` + setting (4 export presets), export/import dialogs + review/sign wizard + + lists/window wiring, export filters, auto slideshow with per-second rate + + loop option, audio send/receive buttons, and the crash fixes + (`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry + 56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`. +- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed — + `copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor + serialize/deserialize, `copy_status_table()`. Working tree clean after the + branch's three commits. +- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*` + failures; ruff no new violations; smoke + `build_zip.py` + + external-zip OK; pyright clean. The isolated + `test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired` + failure is pre-existing test pollution (fails identically on clean HEAD, + passes inside the full batch) — not caused by entry 57. +- Remaining: manual on-device walkthrough of the QR path (and, if wanted, + the audio path — buttons only appear when the `audio_modem` plugin + + `amodem` are installed; see the prerequisites below). + +### In progress: animated-QR interop (BC-UR v1/v2, BBQR) + +- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1** + (BC32 + SHA-256 digest; the bech32_bis checksum variant per + BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal, + CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **BBQR** + (Coinkite `B$…` base32/hex/zlib frames), plus one shared + `AnimatedQrSession` with `detect_format` auto-detection and + `parse_for_detection` frame identity for the GUI debounce. +- Current status as of this session: reference parity, GUI, and tests done; + not yet committed. + - **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005 + test vectors (`Hello, world`, `Hello world`, the long seed vector); + bytewords-minimal round-trips with CRC rejection; UR v2 part encode + + decode is byte-exact against the reference C++ bc-ur encoder for a + single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes), + validating CBOR framing, bytewords, alias+ary-threshold sampling, + xoshiro256** and the XOR mix. + - **Sessions:** UR v2 single-part (no seq header), out-of-order frames, + duplicate drops, solve with a missing pure fragment (a second redundant + mixed wave is emitted by `ur2_frames`), UR v1 single-part + (digest-less `ur:bytes/` accepted) and multipart, BBQR full-frame + decode in any order for Z/2/H encodings. + - **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`, + `TransferConflictError` on a frame from a different transfer, + `SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only. + - **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default, + BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now + routes every frame through `detect_format` + + `AnimatedQrSession.add_part` with the shared + `qr_import_accept_frame(state, fmt, session_key, frame_total, index, + payload, stable_reads=2)` debounce (reset on session-key change). + `_review_and_sign` resolves the session to the transfer text and decodes + parts uniformly across formats. + - **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the + C++-reference parity vectors and BC32 spec vectors) and the extended + `tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed + files; pyright 0 errors; smoke test, `build_zip.py` and + `external_zip_test.py` green. Only the pre-existing failures remain + (`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution). + - **Any remaining work:** manual on-device walkthrough of the QR path with + the new formats; optionally validate against third-party libraries + (`ur`, `bbqr`) once available; add the docstrings/branch notes already + captured in `ag1.md`/`ag2.md` context where needed. + +**Dev-box audio prerequisites (audio_modem channel):** +See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the +two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal): +- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so` + (the unversioned name the plugin loads; Debian ships only `.so.2`). +- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` → + `tobytes()` (already applied locally). Both are runtime-env fixes, not repo + changes; see CHANGELOG entry 56. +- To loop-test on one machine without speakers/mic: during receive, + `pactl set-default-source .monitor` (restore after). diff --git a/PLAN_QR_TRANSFER.md b/PLAN_QR_TRANSFER.md new file mode 100644 index 0000000..d371019 --- /dev/null +++ b/PLAN_QR_TRANSFER.md @@ -0,0 +1,499 @@ +# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned) + +> Goal: let the user move an inheritance ("will") between devices over two +> air-gap channels: +> +> 1. **QR codes** (primary): export the **valid** inheritance transactions as +> a sequence of QR codes, and import them back on another machine with the +> camera; +> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is +> enabled): send/receive the same payload through the PC speaker + +> microphone. +> +> Both channels converge on the same review-and-sign flow afterwards. +> +> Status: APPROVED by owner (2026-08-25). No code written yet — this document +> is the implementation contract. Work top-down through §9 Checklist. +> +> Chat language: Italian; this document is in English (global rule R1). + +--- + +## 1. Scope + +### In scope + +- **Desktop PyQt6 GUI** (primary, implemented by this plan): + - New plugin setting: QR chunk size, offered as **4 standard presets**. + - *"Export via QR"* action: serializes the **valid** will transactions, + optionally compresses, splits the resulting string into fixed-size frames, + shows one QR at a time with prev/next navigation, live re-chunking and + progress (`i di N`). + - *"Import via QR"* action: camera capture dialog with a slot grid + (1..N); the user selects which shot he is about to capture, scans, the + frame lands in its slot; when 1..N are filled the payload is assembled, + parsed into `WillItem`s, validity-checked locally. + - **Post-capture flow (owner decision D6, amended)**: after capture + completes there is NO read-only preview. Instead a review-and-sign wizard + walks through every transaction one at a time showing **outputs + (address + amount), total outputs, total fees**, signs it (wallet + password asked once), and at the end **proposes exporting the signed + transactions** (to file and/or back via QR). +- **Audio-modem channel** (owner decision D7): when the Electrum + `audio_modem` plugin is enabled and available, the export dialog gains a + *"Send via Audio Modem…"* button and the import dialog a *"Receive via + Audio Modem…"* button, reusing the same transfer string (no QR framing). +- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs + (owner decision D1). No QML code in this feature. + +### Out of scope + +- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2). +- Merging imported wills into the live wallet state (existing Merge flows + stay unchanged). +- Broadcast of the reviewed transactions (user exports them; broadcasting + remains an explicit action elsewhere). +- CLI/cmdline parity for QR transfer. + +--- + +## 2. Owner decisions (locked) + +| # | Decision | +|---|----------| +| D1 | QML part = update `QML_PLAN.md` document only; implementation later. | +| 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) 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. | + +--- + +## 3. Verified facts (research done on the local checkouts) + +All verified by reading source; references are `file:line`. + +| # | Fact | Where | +|---|------|-------| +| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` | +| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` | +| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` | +| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` | +| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` | +| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` | +| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) | +| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` | +| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` | +| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` | +| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` | +| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` | +| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` | +| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` | +| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib | +| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` | +| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` | +| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` | +| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` | +| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` | +| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` | +| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check | +| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 | + +--- + +## 4. Wire format specification + +### 4.1 Transfer string + +``` +transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid ) +``` + +- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for + partially-signed ones. Neither alphabet contains `\n` or `|`, so both are + safe delimiters. +- Ordering: ascending `tx.txid()` → deterministic output for identical input. +- If compression enabled (D4): + `transfer_string = base64_ascii( zlib_compress( transfer_string ) )`. + +### 4.2 Frame layout (one frame = content of ONE QR code) + +Wire format v2 (compact, current export): + +``` +BAL1 +``` + +- 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). 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) + +| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M | +|--------------|------------------------------|--------------------------| +| Small (low-res cameras) | 150 | ~v10 | +| Medium | 400 | ~v15 | +| Large | 900 | ~v22 | +| XL (high-res cameras) | 1800 | ~v40 | + +EC level fixed **M** for scan reliability (D5). Presets live in +`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them. + +### 4.4 Audio-modem channel (D7, F20-F23) + +- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames + (`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin + compresses internally; double compression wastes airtime). +- The existing core functions `encode_transfer(tx_strings, + compress=False)` + `decode_transfer(text, compressed=False)` are reused + unchanged; only the transport differs. +- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds + no setting of its own. + +--- + +## 5. New core module — `bal/core/qrtransfer.py` + +GUI-free (never imports Qt — house rule). Public API: + +```python +MAGIC = "BALQR" +VERSION = 1 +FLAG_COMPRESSED = "Z" +CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table + +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 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); 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 + frame header), concatenate payloads in order, decode flags -> + transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError.""" + +def decode_transfer(transfer_string: str, compressed: bool) -> list[str] + """Inverse of encode_transfer -> list of tx strings.""" +``` + +Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`, +`InconsistentTotalError`. All docstrings/comments English; ruff-clean +(line-length 88, E501 ignored). + +--- + +## 6. Settings (Qt) + +1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add + + ```python + self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) + ``` + +2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844): + append row **13** — visible in BASIC and ADVANCED (do NOT wrap with + `_hide_if_basic`): + + - Label: `"QR Code Size"` + - `QComboBox` fed from `CHUNK_PRESETS`; item text e.g. + `"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged` + → `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from + `QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value + was customized). + - `HelpButton` text: explains trade-off (small QR = more shots but easier + to scan with poor cameras; large QR = fewer shots, needs good camera) + and that the size can also be changed inside the export dialog. + - Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)` + pattern (plugin.py:684). + +--- + +## 7. Qt export flow + +### 7.1 Entry point + +`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670): + +```python +export_menu.addAction(_("Via QR…"), self.export_will_valid_qr) +``` + +New `WillList.export_will_valid_qr()` mirrors `export_will_valid` +(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty → +`show_message(_("No valid will item to export"))`, else +`self.bal_window.export_will_via_qr(will=subset)`. + +### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near +`export_will`) + +- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]` + (F11). +- Mark exported items `EXPORTED` (parity with `export_json_file`, + window.py:1607-1609) — only when `will` came from the live list. +- Open `WillQrExportDialog(self, tx_strings)`. + +Shared helper used by both dialogs: + +```python +def get_audio_modem_plugin(self): # on BalWindow + """Return the loaded audio_modem plugin if enabled AND available + (amodem importable), else None. Never raises.""" + p = self.window.plugins.get("audio_modem") # F22 + return p if p is not None and p.is_available() else None +``` + +### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`) + +Layout: + +``` +[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] +``` + +Behaviour: + +- 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` + (`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing + `qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting + via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines. +- Prev/Next wrap or disable at ends (disable chosen: clearer). +- PNG export optional convenience via existing + `getSaveFileName` + `QWidget.grab()` (same trick as + `qrcodewidget.py:110`). +- **Send via Audio Modem…** (D7): shown only when + `bal_window.get_audio_modem_plugin()` returns a usable instance (below); + otherwise hidden. Handler: re-encode the payload **plain** + (`encode_transfer(tx_strings, compress=False)`) and call the plugin's + `_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns + progress/cancellation (F20). Tooltip when hidden is unnecessary; instead, + if the plugin is enabled but `is_available()` is False, show an info + message pointing to `pip install amodem` + portaudio (F22). + +--- + +## 8. Qt import flow + review/sign wizard + +### 8.1 Entry point + +`lists.py` toolbar menu, next to Import/Merge (lines 670-671): + +```python +menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr()) +``` + +`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`. + +### 8.2 `WillQrImportDialog(BalDialog)` + +State: `self.frames: dict[int, str]`, `self.total: int | None`, +`self.target_index: int | None`. + +Layout: + +``` +«Captured k of N» [Scan ▶] [Reset] +[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target] +hint line («Select a slot, then scan» / «Scan the first QR») + [Receive via Audio Modem…] [Review & Sign ▶] [Close] +``` + +Behaviour: + +- **Scan** → `scan_qrcode_from_camera(parent=self, + config=self.bal_window.window.config, callback=self._on_scan)` + (F13). One-shot per press; dialog stays open between shots (simplest, + matches Electrum UX; no continuous mode). +- `_on_scan(success, error, data)`: + - failure → `show_error(error)` (covers missing zbar/camera too); + - `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`; + - first valid frame adopts `total` and materializes the slot grid; + - frame whose `total` ≠ adopted total → warn + offer Reset (user may have + restarted the export with another size); + - valid → `frames[index] = payload`; auto-advance `target_index` to the + lowest missing index; refresh grid + counter. +- Clicking an empty slot sets `target_index` (owner requirement: manual + shot selection); a filled slot click asks to overwrite. +- **Receive via Audio Modem…** (D7): shown only when + `bal_window.get_audio_modem_plugin()` returns a usable instance. Handler: + build a tiny adapter object exposing `setText(str)` that stores the text + and invokes the shared post-receive continuation, then call + `plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the + received string is treated as the **whole payload**: skip frames/slots + entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's + item-building step (WillItem construction + validity pass + wizard). + Errors from the modem surface through the plugin's own dialog; empty + result (user cancelled) is silently ignored. +- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`: + runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError` + surfaces as `show_error` and keeps the dialog open. +- Build items exactly like `merge_single_transaction` (F4): + `WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string + are collected and reported at the end (bad string ≠ fatal for the rest). +- Local validity pass (F5 recipe) on the resulting dict; items failing + `VALID` are dropped and listed in a warning. Set + `wi.set_status("IMPORTED", True)` on survivors (mirrors + `import_will_into_details`, window.py:1753-1754). +- Then `close()` and start the wizard (§8.3) with the valid subset. Empty + result → stop with a message. + +### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6) + +Constructed with `(bal_window, willitems: dict[str, WillItem])` — the +imported subset lives **outside** the live wallet state (external mode, +F3). + +Flow: + +1. **Password once**: `password = bal_window.get_wallet_password()` + (window.py:1088-1100). Returns `False` on cancel → abort wizard; `None` + means unencrypted wallet → proceed without password. +2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by + txid like export): + + ``` + Tx 2 of 5 — a1b2…c3d1 (short txid) + Locktime: 2033-04-05 Status: unsigned (0/1 sigs) + ┌ outputs ─────────────────────────────────┐ + │ bc1q…heir1 0,042 BTC │ + │ bc1q…willexec fee 0,00012 BTC │ + │ bc1q…change 0,00988 BTC │ + └───────────────────────────────────────────┘ + Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB) + [Sign & Next ▶] [Skip] [Cancel all] + ``` + - Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()` + style helpers already imported in the qt layer; value via + `bal_window.window.format_amount`). + - Totals: `output_value()` sum; fees via `input_value() - output_value()` + after resolving inputs with `Will.add_info_from_will(will, wid, wallet)` + (F10); `-1`/unknown handled like widgets.py:1319-1324 (F9). +3. **Sign & Next** → sign this single tx through a **refactored helper** + extracted from the loop body of `sign_transactions` + (window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept + byte-equivalent; batch method calls the helper per iteration so existing + behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as + today; then advance. +4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the + already-signed txs remain in the wizard's local dict (still exportable — + confirmation dialog warns about skipped ones). +5. **Summary page**: `signed X of Y`, skipped/failed lists, then: + + ``` + [Save signed file…] [Show QR…] [Close] + ``` + - *Save file* = existing JSON path: `export_meta_gui(window, + "will.json", writer)` writing `{wid: wi.to_dict()}` of the signed + subset (same serializer as `export_json_file`, window.py:1605). + - *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed + subset (the online machine can scan them straight into Merge). + - Nothing touches `self.willitems`/history (external-mode rule, F3). + +--- + +## 9. Checklist (execution order — tick here when resuming work) + +- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py` + (cases: round-trip plain/compressed; boundaries: len%size==0, size>len, + min-size guard; bad magic/version; missing middle frame; duplicate + overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs + qrcode capacities F15). Run: + `QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py` +- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16 + (ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run. +- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin` + helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`, + audio-modem send button (§7). +- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2), + incl. camera error paths, audio-modem receive button (local mirror of + `_recv`, `setText` sink replaced by a callback), plain-payload fast + path into the wizard. +- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog` + (§8.3). Regression-gate: full batch sign still green + (`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch + including new `tests/test_gui_qr_transfer.py`). +- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models += + `BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`), + Phase 3 += dedicated views `BalQrExportPage.qml` / + `BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the + "chunked streams deferred" note, rewrite R6 mitigation, add Android + caveat quoting F17 with file/paste fallback; README/HANDOFF sections; + CHANGELOG numbered entry 56 at END (house rule). +- [x] **P6** Release hygiene: `python3 build_zip.py` + + `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py + electrum.plugins.bal` + external-zip test; ruff (repo venv + `venv/bin/ruff`) no NEW violations; pyright false-positive policy per + AGENTS.md. Version bump only via `make-release.sh` (owner-driven). + Docs: document audio-modem as OPTIONAL channel — requires the + Electrum `audio_modem` plugin enabled plus `pip install amodem` + and libportaudio (not installed in the dev runtime env today, F22); + manual test matrix gains an audiomodem round-trip row (two machines, + default slowest bitrate) marked optional/skippable when hardware + unavailable. + +--- + +## 10. Risks & mitigations + +| Risk | Mitigation | +|------|------------| +| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible | +| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) | +| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) | +| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 | +| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage | +| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback | +| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency | +| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) | +| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads | +| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback | + +--- + +## 11. Findings log (append-only) + +- 2026-08-25: plan drafted after code exploration; owner answered D1-D6 + (D6 amended live from "preview dialog" to "review+sign wizard"). +- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip + guarantees) — both shaped §§4/7. +- 2026-08-25: owner requested an audio-modem transfer path → researched + `electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons + in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s + only contract is `parent.setText(blob)` (F21) → thin adapter object; and + BAL must not chunk/compress on this channel (F23). `amodem` is NOT in + the runtime env yet — feature is strictly optional. diff --git a/README.md b/README.md index ca3f196..f7c8c58 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ bal/ the installable Electrum plugin package │ ├── willexecutors.py │ ├── checkalive.py │ ├── reminders.py +│ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler +│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (stdlib-only) │ └── input_rules.py ├── cli/ headless command-line layer (no Qt) │ ├── commands.py bal_* daemon commands (@plugin_command) @@ -87,6 +89,31 @@ Copy the `bal/` directory into your Electrum installation's `electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json` exists, then enable it from **Tools → Plugins**. +## Transfer a will with QR codes (or audio) + +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) 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 +[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the BAL QR wire-format spec. + +### Animated-QR formats (interop) + +BAL QR is the default export format, but the export page's **Format** selector +also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2** +(`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by +BitKit) animated-QR sequences. The importer auto-detects the format of each +code it sees, so any of the four formats can be imported on a BAL device, and +a BAL export can be imported by any tool that understands these standards. +UR v2 imports tolerate out-of-order and duplicate frames (fountain decoding); +BBQR frames may arrive in any order. Rotation/redundancy caps and the +32 MB message limit (zlib-bomb guard) bound untrusted scanner input. + ## Command-line / headless usage BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..e76be97 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,10 @@ +.gradle/ +build/ +local.properties +.idea/ +*.apk +*.aab +captures/ +.externalNativeBuild/ +.cxx/ +*.hprof \ No newline at end of file diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..cf15a05 --- /dev/null +++ b/android/README.md @@ -0,0 +1,153 @@ +# BAL Reader (Android) + +A minimal Android app that reads a Bitcoin will exported by the +[BAL Electrum plugin](https://bitcoin-after.life) directly from your screen, +then lets you view, copy, share, or save the recovered data. **Reader only** — +it never signs or broadcasts. + +It decodes **all four** transfer formats the plugin can export, auto-detecting +the format from the first frame: + +- **BAL QR** (the plugin's default), single- and multi-frame, plain and + zlib-compressed, compact `BAL1` header (legacy `BALQR1` frames are still + accepted on import); +- **BC-UR v1** (single-part and `NofM` multipart); +- **BC-UR v2** (single-part and XOR-fountain multipart — it tolerates dropped, + repeated, and out-of-order frames); +- **BBQR** (`Z`/`H`/`2` encodings). + +Both payload kinds are handled: the **whole-will JSON** and the plain +**transaction-hex list**. + +## How decoding works + +The app does not reimplement the QR formats. It bundles the plugin's own +codec modules — `bal/core/__init__.py`, `bal/core/animated_qr.py`, +`bal/core/qrtransfer.py` — and runs them verbatim through **Chaquopy** (CPython +on Android). Kotlin is only camera glue and UI: + +``` +Camera (CameraX) → ML Kit QR detection (on-device, no API key) + → BalDecoder.add(text) → bal.core.animated_qr.AnimatedQrSession + → when done → BalDecoder.finish() + = session.resolve() → qrtransfer.decode_transfer() + → balreader.payload.decode_will_payload() + → ResultActivity: view / copy / share / save +``` + +The decode tail mirrors the plugin's import dialog function-for-function, and +`android/test_chain/verify_chain.py` proves the bundled code decodes every +format the way the desktop import does (including scrambled, duplicated, and +missing frames). + +## Repository layout + +``` +android/ +├── app/src/main/ +│ ├── AndroidManifest.xml +│ ├── java/life/after/bitcoin/ +│ │ ├── BalDecoder.kt Chaquopy bridge over the bundled codecs +│ │ ├── MainActivity.kt camera + ML Kit scan loop + progress +│ │ └── ResultActivity.kt viewer (copy / share / save) +│ └── python/ bundled Python (regenerate, do not hand-edit) +│ ├── bal/core/ SYNCED COPY of the plugin codecs +│ └── balreader/payload.py verbatim copy of dialogs.decode_will_payload +├── scripts/ +│ ├── sync_codecs.py re-copy + verify the bundled codecs +│ └── build_apk.py resync codecs, run Gradle, print APK + sha256 +└── test_chain/verify_chain.py decode-chain simulation for all formats +``` + +## Build + +You need Android Studio (Jellyfish or newer), JDK 17, an Android SDK with +platform 35, and a network connection for the first Gradle sync. + +1. Open this `android/` folder in Android Studio and let it sync (it will + fetch the Gradle wrapper 8.14, AGP 8.10.0, Kotlin 2.0.21, Chaquopy 17.0.0, + CameraX 1.3.4, and ML Kit). +2. Connect a phone (API 24+) or start an emulator and press **Run**. +3. Grant the camera permission when asked. + +Alternatively, from the command line (from the repository root): + +```bash +python3 android/scripts/build_apk.py # debug APK + sha256 +python3 android/scripts/build_apk.py --release # (unsigned) release APK +``` + +The script re-synchronises the bundled codec modules first (so the APK always +carries the current `bal/core` sources), runs `./gradlew`, and prints the APK +path, size and sha256. Flags: `--no-sync` (skip the re-sync), `--offline` +(Gradle without downloads), `--clean`, `--verbose`. + +Equivalent raw Gradle call: + +```bash +cd android +./gradlew assembleDebug # APK: android/app/build/outputs/apk/debug/app-debug.apk +``` + +### If the Gradle wrapper jar is missing + +`gradle/wrapper/gradle-wrapper.jar` is committed so `./gradlew` works out of +the box. If it is ever absent, Android Studio regenerates it on the first +sync; no manual steps needed. + +## Use + +1. In Electrum + BAL, open the will's **export** dialog. +2. Pick a format — start with the default **BAL QR**, then try **BC-UR v1**, + **BC-UR v2**, and **BBQR**. +3. Make sure the wording toggle shows a payload (business logic), then display + the animated QR and keep it on screen. +4. Point the phone at the screen. The header shows the detected format and + `received / total`; scanning stops automatically when the transfer is + complete. +5. On the result screen: **Copy** the raw transfer, **Share** it, **Save** it + as `will.json` (whole will) or `will_tx.txt` (transaction list), or press + **Scan another**. + +Notes: + +- Keep the phone still and the whole QR inside the frame (the codec dedups + repeated frames, so a slow capture is fine). +- If the camera glares off the screen, reduce brightness or tilt slightly. +- If scanning jumps between exports, the app detects the format switch, + resets, and asks you to let it re-scan. + +## Keeping the bundled code in sync with the plugin + +The codecs under `app/src/main/python/bal/core/` are **committed copies** for +deterministic builds, but they must stay identical to the plugin. Re-run this +after changing `bal/core/animated_qr.py` or `bal/core/qrtransfer.py` (and +after any change to `decode_will_payload` in `bal/gui/qt/dialogs.py`, which +mirrors `balreader/payload.py`): + +```bash +python3 android/scripts/sync_codecs.py # copy +python3 android/scripts/sync_codecs.py --check # verify only (CI-friendly) +python3 android/test_chain/verify_chain.py # full decode-chain regression +``` + +`verify_chain.py` fails if the app's `balreader/payload.py` ever drifts from +the plugin's `decode_will_payload` (AST identity + result parity). + +## Version pins (see `PLAN_ANDROID_READER.md`) + +| Item | Version | +|---|---| +| AGP | 8.10.0 | +| Gradle | 8.14 (wrapper) | +| Kotlin | 2.0.21 | +| Chaquopy | 17.0.0 (Python 3.12) | +| compile / target / min SDK | 35 / 35 / 24 | +| CameraX | 1.3.4 | +| ML Kit barcode-scanning | 17.3.0 | +| JDK | 17 | + +## License + +MIT. The bundled Python codec files inherit the plugin's MIT license +(`bal/LICENSE`); see `app/src/main/python/bal/` for attribution. \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..dbd7079 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.chaquo.python") +} + +android { + namespace = "life.after.bitcoin" + compileSdk = 35 + + defaultConfig { + applicationId = "life.after.bitcoin" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "0.1.0" + + // Chaquopy requires explicit ABI filters. Python 3.12 ships only for + // 64-bit ABIs: phones (arm64-v8a) + the common emulator image (x86_64). + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + viewBinding = true + } +} + +chaquopy { + defaultConfig { + version = "3.12" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + + // CameraX + implementation("androidx.camera:camera-core:1.3.4") + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + + // ML Kit on-device barcode scanning (QR only, no API key) + implementation("com.google.mlkit:barcode-scanning:17.3.0") +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e20d335 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Chaquopy Python runtime. +-keep class com.chaquo.python.** { *; } + +# ML Kit barcode scanning. +-keep class com.google.mlkit.** { *; } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc2f69d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt b/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt new file mode 100644 index 0000000..e75d627 --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/BalDecoder.kt @@ -0,0 +1,129 @@ +package life.after.bitcoin + +import android.content.Context +import com.chaquo.python.PyObject +import com.chaquo.python.PyException +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import org.json.JSONException +import org.json.JSONObject + +/** + * Chaquopy bridge over the plugin's animated-QR codecs + * (``bal.core.animated_qr``, ``bal.core.qrtransfer``, bundled verbatim under + * ``app/src/main/python``). + * + * The decode tail mirrors the plugin's import dialog exactly, and runs + * entirely inside Python (``balreader.bridge.finish``) so no container + * conversion happens across the bridge: + * + * session.resolve() -> qrtransfer.decode_transfer() -> decode_will_payload() + * + * Kotlin only feeds frames, reads progress, and renders the JSON the bridge + * returns. + */ +class BalDecoder(private val context: Context) { + + /** Outcome of feeding one scanned frame to the session. */ + enum class AddResult { + /** A new frame was accepted. */ + OK, + + /** The frame was already present (duplicate); ignore. */ + DUP, + + /** The frame was not a supported QR transfer; ignore. */ + GARBAGE, + + /** The QR switched to a different transfer; caller should rescan. */ + CONFLICT, + } + + /** Fully decoded transfer, mirroring the plugin's import tail. */ + data class DecodedResult( + val kind: String, // "will", "txs" or "error" + val payload: String, // raw transfer text (JSON or joined tx hexes) + val parts: List // [whole-will JSON] or [tx hex strings] + ) + + private val python: Python by lazy { + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + Python.getInstance() + } + private val animatedQr by lazy { python.getModule("bal.core.animated_qr") } + private val bridge by lazy { python.getModule("balreader.bridge") } + + private var session: PyObject? = null + + /** Start a fresh receive session (clears any accumulated frames). */ + fun reset() { + session = null + } + + private fun sessionOrCreate(): PyObject { + val current = session + if (current != null) { + return current + } + return animatedQr.callAttr("AnimatedQrSession").also { session = it } + } + + /** Feed one scanned frame string; see [AddResult] for semantics. */ + fun add(text: String): AddResult { + return try { + when (sessionOrCreate().callAttr("add_part", text).toString()) { + "dup" -> AddResult.DUP + else -> AddResult.OK + } + } catch (e: PyException) { + val msg = e.message ?: "" + // TransferConflictError: "Switched QR format mid-import (.. -> ..)". + if (msg.contains("Switched QR format")) { + AddResult.CONFLICT + } else { + AddResult.GARBAGE + } + } + } + + /** The detected wire format ("balqr"/"ur1"/"ur2"/"bbqr"), or null. */ + val format: String? + get() = runCatching { + session?.get("format")?.toString()?.takeIf { it != "None" } + }.getOrNull() + + /** Number of distinct frames accepted. */ + val received: Int + get() = session?.get("received")?.toInt() ?: 0 + + /** Total frames expected for the current transfer (0 until known). */ + val total: Int + get() = session?.get("total")?.toInt() ?: 0 + + /** True once the whole transfer has been captured. */ + val done: Boolean + get() = session?.get("done")?.toBoolean() ?: false + + /** + * Resolve the completed session into a [DecodedResult]. The decoding runs + * in Python (``balreader.bridge.finish``) using the exact same three steps + * as the plugin's import dialog. + */ + fun finish(): DecodedResult { + val jsonText = bridge.callAttr("finish", sessionOrCreate()).toString() + return try { + val obj = JSONObject(jsonText) + val partsArray = obj.getJSONArray("parts") + val parts = (0 until partsArray.length()).map { partsArray.getString(it) } + DecodedResult( + kind = obj.getString("kind"), + payload = obj.getString("payload"), + parts = parts, + ) + } catch (e: JSONException) { + DecodedResult(kind = "error", payload = jsonText, parts = emptyList()) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt b/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt new file mode 100644 index 0000000..bdc0c2f --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/FrameProgressBar.kt @@ -0,0 +1,70 @@ +package life.after.bitcoin + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View +import androidx.core.content.ContextCompat + +/** + * Horizontal progress bar showing how many QR frames of the current transfer + * have been captured (`received / total`), with a filled mint segment + * proportional to the fraction. A thin decorative strip over the camera + * preview; the exact count stays in the header's "n / N" label. + */ +class FrameProgressBar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : View(context, attrs) { + + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ContextCompat.getColor(context, R.color.frame_fill) + } + private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ContextCompat.getColor(context, R.color.frame_track) + } + private val trackRect = RectF() + private val fillRect = RectF() + private val cornerRadius = dp(3f) + + private var fraction = 0f + + /** Reset to an empty bar. */ + fun reset() { + fraction = 0f + invalidate() + } + + /** + * Update the fill to [received] out of [total] frames captured. + * A zero/unknown total clears the bar. + */ + fun set(total: Int, received: Int) { + fraction = if (total > 0) { + received.toFloat() / total.toFloat() + } else { + 0f + } + invalidate() + } + + private fun dp(value: Float): Float = + resources.displayMetrics.density * value + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (width <= 0 || height <= 0) { + return + } + trackRect.set(0f, 0f, width.toFloat(), height.toFloat()) + canvas.drawRoundRect(trackRect, cornerRadius, cornerRadius, trackPaint) + if (fraction <= 0f) { + return + } + val fillWidth = width * fraction.coerceIn(0f, 1f) + fillRect.set(0f, 0f, fillWidth, height.toFloat()) + canvas.drawRoundRect(fillRect, cornerRadius, cornerRadius, fillPaint) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/MainActivity.kt b/android/app/src/main/java/life/after/bitcoin/MainActivity.kt new file mode 100644 index 0000000..49cdb5d --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/MainActivity.kt @@ -0,0 +1,209 @@ +package life.after.bitcoin + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import life.after.bitcoin.BalDecoder.AddResult +import life.after.bitcoin.databinding.ActivityMainBinding +import java.util.concurrent.Executors + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private lateinit var decoder: BalDecoder + private lateinit var barcodeScanner: BarcodeScanner + + private val analyzerExecutor = Executors.newSingleThreadExecutor() + private var finished = false + private var cameraBound = false + private var lastAnalysisMs = 0L + + private val formatLabels: Map by lazy { + mapOf( + "balqr" to getString(R.string.format_balqr), + "ur1" to getString(R.string.format_ur1), + "ur2" to getString(R.string.format_ur2), + "bbqr" to getString(R.string.format_bbqr), + ) + } + + private val requestCameraPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + startCamera() + } else { + Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_LONG).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + decoder = BalDecoder(applicationContext) + barcodeScanner = BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + ) + + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } else { + requestCameraPermission.launch(Manifest.permission.CAMERA) + } + } + + override fun onResume() { + super.onResume() + // Returning from the result screen starts a new scan. + if (finished) { + finished = false + decoder.reset() + binding.tvFormat.text = getString(R.string.format_placeholder) + binding.tvProgress.text = "0 / 0" + binding.tvStatus.setText(R.string.status_waiting) + binding.frameBar.reset() + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED + ) { + startCamera() + } + } + + override fun onDestroy() { + cameraProvider?.unbindAll() + barcodeScanner.close() + analyzerExecutor.shutdown() + super.onDestroy() + } + + private var cameraProvider: ProcessCameraProvider? = null + + private fun startCamera() { + if (cameraBound) { + return + } + val providerFuture = ProcessCameraProvider.getInstance(this) + providerFuture.addListener({ + val provider = providerFuture.get() + cameraProvider = provider + + val preview = Preview.Builder().build() + preview.setSurfaceProvider(binding.previewView.surfaceProvider) + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(analyzerExecutor) { proxy -> analyze(proxy) } + + try { + provider.unbindAll() + provider.bindToLifecycle( + this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis + ) + cameraBound = true + } catch (e: Exception) { + Log.e(TAG, "Failed to bind camera", e) + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun analyze(proxy: ImageProxy) { + val now = System.currentTimeMillis() + if (finished || now - lastAnalysisMs < 100) { + proxy.close() + return + } + lastAnalysisMs = now + val image = proxy.image + if (image == null) { + proxy.close() + return + } + try { + val input = InputImage.fromMediaImage(image, proxy.imageInfo.rotationDegrees) + barcodeScanner.process(input) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + val value = barcode.rawValue + if (!value.isNullOrEmpty()) { + handleFrame(value) + break + } + } + } + .addOnCompleteListener { proxy.close() } + } catch (e: Exception) { + Log.w(TAG, "Frame analysis failure", e) + proxy.close() + } + } + + private fun handleFrame(value: String) { + if (finished) { + return + } + when (decoder.add(value)) { + AddResult.OK -> { + Log.i(TAG, "frame ok fmt=${decoder.format} rcvd=${decoder.received}/${decoder.total} done=${decoder.done}") + binding.tvFormat.text = decoder.format?.let { formatLabels[it] } + ?: getString(R.string.format_placeholder) + binding.tvProgress.text = + getString(R.string.progress_fmt, decoder.received, decoder.total) + binding.frameBar.set(decoder.total, decoder.received) + if (decoder.done) { + finishScan() + } + } + AddResult.DUP -> Log.i(TAG, "frame dup") + AddResult.GARBAGE -> Log.w(TAG, "frame garbage") + AddResult.CONFLICT -> { + Log.w(TAG, "format conflict - resetting") + decoder.reset() + binding.tvFormat.text = getString(R.string.format_placeholder) + binding.tvProgress.text = "0 / 0" + binding.tvStatus.setText(R.string.conflict_message) + binding.frameBar.reset() + } + } + } + + private fun finishScan() { + if (finished) { + return + } + finished = true + val result = decoder.finish() + Log.i(TAG, "FINISH kind=${result.kind} payload=${result.payload.length}B parts=${result.parts.size}") + val intent = Intent(this, ResultActivity::class.java).apply { + putExtra(ResultActivity.EXTRA_KIND, result.kind) + putExtra(ResultActivity.EXTRA_PAYLOAD, result.payload) + putStringArrayListExtra(ResultActivity.EXTRA_PARTS, ArrayList(result.parts)) + } + startActivity(intent) + } + + companion object { + private const val TAG = "BalReader" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt b/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt new file mode 100644 index 0000000..4b0bb85 --- /dev/null +++ b/android/app/src/main/java/life/after/bitcoin/ResultActivity.kt @@ -0,0 +1,100 @@ +package life.after.bitcoin + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import life.after.bitcoin.databinding.ActivityResultBinding +import org.json.JSONException +import org.json.JSONObject + +class ResultActivity : AppCompatActivity() { + + private lateinit var binding: ActivityResultBinding + private var kind = "txs" + private var payload = "" + private var parts: List = emptyList() + + private val saveWillPicker = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? -> + saveTo(uri) + } + private val saveTxsPicker = + registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri: Uri? -> + saveTo(uri) + } + + private fun saveTo(uri: Uri?) { + if (uri != null) { + contentResolver.openOutputStream(uri)?.use { it.write(payload.toByteArray()) } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityResultBinding.inflate(layoutInflater) + setContentView(binding.root) + + kind = intent.getStringExtra(EXTRA_KIND) ?: "txs" + payload = intent.getStringExtra(EXTRA_PAYLOAD) ?: "" + parts = intent.getStringArrayListExtra(EXTRA_PARTS) ?: emptyList() + + binding.tvKind.text = + getString(if (kind == "will") R.string.result_kind_will else R.string.result_kind_txs) + binding.tvContent.text = pretty(payload) + + binding.btnCopy.setOnClickListener { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("BAL transfer", payload)) + Toast.makeText(this, R.string.copied_toast, Toast.LENGTH_SHORT).show() + } + binding.btnShare.setOnClickListener { + val send = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, payload) + } + startActivity(Intent.createChooser(send, null)) + } + binding.btnSave.setOnClickListener { saveFile() } + binding.btnScanAnother.setOnClickListener { finish() } + } + + private fun pretty(json: String): String { + if (kind == "will") { + try { + return JSONObject(json).toString(2) + } catch (_: JSONException) { + return json + } + } + // Transaction list: one numbered line per tx. + if (parts.isNotEmpty()) { + return parts.mapIndexed { i, tx -> "%d. %s".format(i + 1, tx) } + .joinToString("\n") + } + return json + } + + private fun saveFile() { + val name = if (kind == "will") { + getString(R.string.save_file_will) + } else { + getString(R.string.save_file_txs) + } + // ActivityResultContracts.CreateDocument takes the suggested file name; + // it maps it to ACTION_CREATE_DOCUMENT + EXTRA_TITLE internally. + val picker = if (kind == "will") saveWillPicker else saveTxsPicker + picker.launch(name) + } + + companion object { + const val EXTRA_KIND = "kind" + const val EXTRA_PAYLOAD = "payload" + const val EXTRA_PARTS = "parts" + } +} \ No newline at end of file diff --git a/android/app/src/main/python/bal/core/__init__.py b/android/app/src/main/python/bal/core/__init__.py new file mode 100644 index 0000000..6e1c1b2 --- /dev/null +++ b/android/app/src/main/python/bal/core/__init__.py @@ -0,0 +1,21 @@ +""" +bal.core +======== + +Pure business-logic layer of the Bitcoin After Life (BAL) Electrum plugin. + +Everything in this sub-package MUST stay completely free of any GUI / Qt +imports. The rule of thumb is: + + * ``bal.core`` -> "what the plugin does" (inheritance rules, building + and validating transactions, talking to + will-executor servers, persistence helpers). + * ``bal.gui`` -> "how it looks" (Qt widgets, dialogs, list views). + +Keeping the two apart is the main motivation behind this rewrite: the original +code mixed transaction-building logic and presentation inside a single +4000-line ``qt.py`` module, which made the delicate Bitcoin logic hard to audit. + +No behaviour is changed with respect to the original plugin; the code has only +been reorganised and documented. +""" diff --git a/android/app/src/main/python/bal/core/animated_qr.py b/android/app/src/main/python/bal/core/animated_qr.py new file mode 100644 index 0000000..58cfd30 --- /dev/null +++ b/android/app/src/main/python/bal/core/animated_qr.py @@ -0,0 +1,1180 @@ +""" +bal.core.animated_qr +==================== + +GUI-free implementation of the interoperable animated-QR transfer formats +used to move BAL will data between devices. + +Supported wire formats (each self-describing and order-independent on +receive): + +* **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 + SHA-256 digest of the wrapped payload ties the parts together. +* **BC-UR v2** (BCR-2020-005 rev 2 / BCR-2020-012):: + ur:bytes/2-9/ + Fountain-coded parts; each part is a CBOR array + ``[seq_num, seq_len, message_len, checksum, data]`` whose CBOR bytes are + bytewords-minimal encoded with a trailing per-part CRC-32. The + ``checksum`` field holds the CRC-32 of the whole wrapped message, so the + parts are mixable and order-independent. +* **BBQR** (Coinkite):: + B$<2 base36 total><2 base36 index> + Equal-length text frames; the payload is uppercase hex, RFC-4648 + base32, or raw-deflate (``wbits=-10``) zlib plus base32. + +Everything is implemented from scratch on top of the Python standard library +only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a +self-contained bundle with no third-party dependencies (house rule). + +This module never imports Qt or any Electrum GUI code (house rule). +""" + +from __future__ import annotations + +import base64 +import hashlib +import zlib +from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple + +# --------------------------------------------------------------------------- # +# Errors & safety caps +# --------------------------------------------------------------------------- # + + +class AnimatedQrError(ValueError): + """Base error for all animated-QR codec failures.""" + + +class FormatNotDetectedError(AnimatedQrError): + """The scanned text does not look like any known animated-QR format.""" + + +class TransferConflictError(AnimatedQrError): + """An incoming frame belongs to a different transfer than the open one.""" + + +class SessionLimitError(AnimatedQrError): + """A receive session exceeded its safety caps.""" + + +class ChecksumError(AnimatedQrError): + """A part failed its checksum / digest validation.""" + + +# Safety caps for untrusted scanner input. +_MAX_SESSION_PARTS = 20000 +_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 + +# --------------------------------------------------------------------------- # +# CBOR minimals (byte-string envelope + the fountain part header) +# --------------------------------------------------------------------------- # + +_BYTE_STR_RES = 0x40 # byte string, length < 24 +_BYTE_STR_1 = 0x58 # byte string, 1-byte length +_BYTE_STR_2 = 0x59 # byte string, 2-byte length +_BYTE_STR_4 = 0x60 # byte string, 4-byte length +_ARRAY_RES = 0x80 +_UNSIGNED_RES = 0x00 + + +def cbor_byte_string(data: bytes) -> bytes: + """Wrap ``data`` in the minimal CBOR byte-string envelope (0x40..0x60).""" + n = len(data) + if n < 24: + head = bytes([_BYTE_STR_RES + n]) + elif n <= 0xFF: + head = bytes([_BYTE_STR_1, n]) + elif n <= 0xFFFF: + head = bytes([_BYTE_STR_2]) + n.to_bytes(2, "big") + elif n <= 0xFFFFFFFF: + head = bytes([_BYTE_STR_4]) + n.to_bytes(4, "big") + else: + raise AnimatedQrError("payload too large for the UR byte-string envelope") + return head + data + + +def unwrap_ur_cbor(message: bytes) -> bytes: + """Strip the CBOR byte-string envelope, falling back to the raw bytes. + + Receivers keep working even when the emitter embedded the payload without + any CBOR wrapping (some third-party ``ur:bytes`` emitters do). + """ + if not message: + raise AnimatedQrError("empty decoded message") + b0 = message[0] + if _BYTE_STR_RES <= b0 <= 0x57: + header_len, n = 1, b0 - _BYTE_STR_RES + elif b0 == _BYTE_STR_1 and len(message) >= 2: + header_len, n = 2, message[1] + elif b0 == _BYTE_STR_2 and len(message) >= 3: + header_len, n = 3, int.from_bytes(message[1:3], "big") + elif b0 == _BYTE_STR_4 and len(message) >= 5: + header_len, n = 5, int.from_bytes(message[1:5], "big") + else: + return message + if header_len + n != len(message): + raise AnimatedQrError("decoded message has an inconsistent CBOR length") + return message[header_len:] + + +def _cbor_unsigned(value: int) -> bytes: + if value < 24: + return bytes([_UNSIGNED_RES + value]) + if value <= 0xFF: + return bytes([0x18, value]) + if value <= 0xFFFF: + return bytes([0x19]) + value.to_bytes(2, "big") + if value <= 0xFFFFFFFF: + return bytes([0x1A]) + value.to_bytes(4, "big") + return bytes([0x1B]) + value.to_bytes(8, "big") + + +def cbor_part(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> bytes: + """The CBOR body of a BC-UR v2 fountain part (``[seq, seq_len, message_len, checksum, data]``).""" + out = bytearray([_ARRAY_RES + 5]) + out += _cbor_unsigned(seq_num) + out += _cbor_unsigned(seq_len) + out += _cbor_unsigned(message_len) + out += _cbor_unsigned(checksum) + out += cbor_byte_string(data) + return bytes(out) + + +def _need(buf: bytes, pos: int, count: int) -> None: + if pos + count > len(buf): + raise AnimatedQrError("truncated CBOR part header") + + +def _cbor_read_unsigned(buf: bytes, pos: int) -> Tuple[int, int]: + if pos >= len(buf): + raise AnimatedQrError("truncated CBOR part header") + octet = buf[pos] + if octet & 0xE0 != _UNSIGNED_RES: + raise AnimatedQrError("unexpected CBOR type in part header") + pos += 1 + additional = octet & 0x1F + if additional < 24: + return additional, pos + if additional == 24: + _need(buf, pos, 1) + return buf[pos], pos + 1 + if additional == 25: + _need(buf, pos, 2) + return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + if additional == 26: + _need(buf, pos, 4) + return int.from_bytes(buf[pos : pos + 4], "big"), pos + 4 + if additional == 27: + _need(buf, pos, 8) + return int.from_bytes(buf[pos : pos + 8], "big"), pos + 8 + raise AnimatedQrError("unsupported CBOR integer width in part header") + + +def _cbor_read_bytes(buf: bytes, pos: int) -> Tuple[bytes, int]: + if pos >= len(buf): + raise AnimatedQrError("truncated CBOR part header") + octet = buf[pos] + pos += 1 + if octet & 0xE0 != _BYTE_STR_RES: + raise AnimatedQrError("expected a CBOR byte string in part header") + additional = octet & 0x1F + if additional < 24: + n = additional + elif additional == 24: + _need(buf, pos, 1) + n, pos = buf[pos], pos + 1 + elif additional == 25: + _need(buf, pos, 2) + n, pos = int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + elif additional == 26: + _need(buf, pos, 4) + n, pos = int.from_bytes(buf[pos : pos + 4], "big"), pos + 4 + else: + raise AnimatedQrError("unsupported CBOR byte-string width in part header") + _need(buf, pos, n) + return buf[pos : pos + n], pos + n + + +def _cbor_read_array(buf: bytes, pos: int) -> Tuple[int, int]: + if pos >= len(buf): + raise AnimatedQrError("truncated CBOR part header") + octet = buf[pos] + pos += 1 + if octet & 0xE0 != _ARRAY_RES: + raise AnimatedQrError("expected a CBOR array in part header") + additional = octet & 0x1F + if additional < 24: + return additional, pos + if additional == 24: + _need(buf, pos, 1) + return buf[pos], pos + 1 + if additional == 25: + _need(buf, pos, 2) + return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + raise AnimatedQrError("unsupported CBOR array header in part") + + +# --------------------------------------------------------------------------- # +# CRC-32 (same polynomial as ``zlib.crc32``, network byte order) +# --------------------------------------------------------------------------- # + + +def crc32_int(data: bytes) -> int: + """CRC-32 over ``data`` as an unsigned 32-bit integer.""" + return zlib.crc32(data) & 0xFFFFFFFF + + +def crc32_bytes(data: bytes) -> bytes: + """CRC-32 over ``data`` as 4 network-order (big-endian) bytes.""" + return crc32_int(data).to_bytes(4, "big") + + +# --------------------------------------------------------------------------- # +# Bytewords (BCR-2020-012) +# --------------------------------------------------------------------------- # + +_BYTEWORDS = ( + "ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbr" + "ewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadays" + "delidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfa" + "ctfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgift" + "girlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhu" + "tsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjury" + "keepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlo" + "goloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneed" + "newsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolpo" + "sepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesaga" + "scarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoilto" + "mbtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswall" + "wandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzi" + "nczonezoom" +) + +_WORDS = [_BYTEWORDS[i : i + 4] for i in range(0, 1024, 4)] +_DIM = 26 +_WORD_LOOKUP: Optional[List[int]] = None + + +def _word_lookup() -> List[int]: + """First/last-letter lookup table (built lazily, mirrors Bytewords).""" + global _WORD_LOOKUP + if _WORD_LOOKUP is None: + table = [-1] * (_DIM * _DIM) + for i, word in enumerate(_WORDS): + x = ord(word[0]) - ord("a") + y = ord(word[3]) - ord("a") + table[y * _DIM + x] = i + _WORD_LOOKUP = table + return _WORD_LOOKUP + + +def _decode_word(word: str, word_len: int) -> int: + if len(word) != word_len: + raise AnimatedQrError("invalid bytewords word length") + x = ord(word[0]) - ord("a") + y = ord(word[3] if word_len == 4 else word[1]) - ord("a") + if not (0 <= x < _DIM and 0 <= y < _DIM): + raise AnimatedQrError("invalid bytewords characters") + value = _word_lookup()[y * _DIM + x] + if value == -1: + raise AnimatedQrError("invalid bytewords first/last pair") + if word_len == 4: + full = _WORDS[value] + if word[1] != full[1] or word[2] != full[2]: + raise AnimatedQrError("invalid bytewords middle letters") + return value + + +def bytewords_minimal_encode(data: bytes) -> str: + """BCR-2020-012 bytewords-minimal: one two-letter word per byte, then CRC.""" + crc = data + crc32_bytes(data) + return "".join(_WORDS[b][0] + _WORDS[b][3] for b in crc) + + +def bytewords_minimal_decode(text: str) -> bytes: + """Inverse of :func:`bytewords_minimal_encode` (validates the CRC-32).""" + if len(text) % 2: + raise AnimatedQrError("invalid bytewords length (odd)") + values = [_decode_word(text[i : i + 2], 2) for i in range(0, len(text), 2)] + payload = bytes(values) + if len(payload) < 5: + raise AnimatedQrError("bytewords payload too short") + body, checksum = payload[:-4], payload[-4:] + if crc32_bytes(body) != checksum: + raise AnimatedQrError("bytewords CRC-32 mismatch") + return body + + +# --------------------------------------------------------------------------- # +# BC32 (the deprecated bech32-derived codec used by BC-UR v1) +# --------------------------------------------------------------------------- # + +_BC32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_BC32_REV = {ch: i for i, ch in enumerate(_BC32_ALPHABET)} +_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + + +def bech32_polymod(values: Sequence[int]) -> int: + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ value + for i in range(5): + if (top >> i) & 1: + chk ^= _BECH32_GENERATOR[i] + return chk + + +def _bc32_checksum(values: List[int]) -> List[int]: + polymod = bech32_polymod([0] + values + [0] * 6) ^ 0x3FFFFFFF + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def _bc32_verify(values: List[int]) -> bool: + return bech32_polymod([0] + values) == 0x3FFFFFFF + + +def bc32_encode(data: bytes) -> str: + """BCR-2020-005 BC32: bech32 without the human-readable part and divider.""" + acc = 0 + bits = 0 + values: List[int] = [] + for byte in data: + acc = (acc << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + values.append((acc >> bits) & 31) + if bits: + values.append((acc << (5 - bits)) & 31) + values += _bc32_checksum(values) + return "".join(_BC32_ALPHABET[v] for v in values) + + +def bc32_decode(text: str) -> bytes: + """Inverse of :func:`bc32_encode` (validates the 6-char checksum).""" + lowered = text.lower() + try: + values = [_BC32_REV[ch] for ch in lowered] + except KeyError: + raise AnimatedQrError("invalid BC-UR v1 character") from None + if len(values) < 6 or not _bc32_verify(values): + raise AnimatedQrError("invalid BC-UR v1 checksum") + data = values[:-6] + acc = 0 + bits = 0 + out = bytearray() + for value in data: + acc = (acc << 5) | value + bits += 5 + if bits >= 8: + bits -= 8 + out.append((acc >> bits) & 0xFF) + return bytes(out) + + +# --------------------------------------------------------------------------- # +# xoshiro256** + alias sampler (exact ports of the reference RNG chain) +# --------------------------------------------------------------------------- # + +_MASK64 = (1 << 64) - 1 + + +class _Xoshiro256: + """xoshiro256** 1.0, seeded via SHA-256 of a byte sequence.""" + + def __init__(self, seed: bytes): + digest = hashlib.sha256(seed).digest() + self._s = [ + int.from_bytes(digest[offset : offset + 8], "big") + for offset in range(0, 32, 8) + ] + + @staticmethod + def _rotl(x: int, k: int) -> int: + return ((x << k) | (x >> (64 - k))) & _MASK64 + + def next(self) -> int: + result = (self._rotl((self._s[1] * 5) & _MASK64, 7) * 9) & _MASK64 + t = (self._s[1] << 17) & _MASK64 + s = self._s + s[2] ^= s[0] + s[3] ^= s[1] + s[1] ^= s[2] + s[0] ^= s[3] + s[2] ^= t + s[3] = self._rotl(s[3], 45) + return result + + def next_double(self) -> float: + return self.next() / float(1 << 64) + + def next_int(self, low: int, high: int) -> int: + return int(self.next_double() * (high - low + 1)) + low + + +class _RandomAliasSampler: + """Vose's alias method, built in the exact order of the reference code.""" + + def __init__(self, probs: Sequence[float]): + total = sum(probs) + assert total > 0 + n = len(probs) + normalized = [p * float(n) / total for p in probs] + + small: List[int] = [] + large: List[int] = [] + for i in range(n - 1, -1, -1): + (small if normalized[i] < 1 else large).append(i) + + self._probs = [0] * n + self._aliases = [0] * n + while small and large: + a = small.pop() + g = large.pop() + self._probs[a] = normalized[a] + self._aliases[a] = g + normalized[g] += normalized[a] - 1 + (small if normalized[g] < 1 else large).append(g) + + while large: + self._probs[large.pop()] = 1 + while small: + self._probs[small.pop()] = 1 + + def next(self, rng: _Xoshiro256) -> int: + r1 = rng.next_double() + r2 = rng.next_double() + n = len(self._probs) + i = int(float(n) * r1) + return i if r2 < self._probs[i] else self._aliases[i] + + +def choose_fragments(seq_num: int, seq_len: int, checksum: int) -> Set[int]: + """The fragments mixed into a BC-UR v2 fountain part (reference seed math). + + Sequence numbers ``1..seq_len`` emit the pure fragment ``{seq_num - 1}``; + every larger sequence number deterministically mixes a pseudo-random + subset of fragments seeded by ``SHA256(seq ‖ checksum)``. + """ + if seq_num <= seq_len: + return {seq_num - 1} + seed = seq_num.to_bytes(4, "big") + checksum.to_bytes(4, "big") + rng = _Xoshiro256(seed) + probs: List[float] = [1.0 / i for i in range(1, seq_len + 1)] + degree = _RandomAliasSampler(probs).next(rng) + 1 + remaining = list(range(seq_len)) + shuffled: List[int] = [] + while remaining: + index = rng.next_int(0, len(remaining) - 1) + shuffled.append(remaining.pop(index)) + return set(shuffled[:degree]) + + +def _partition_message(message: bytes, fragment_len: int) -> List[bytes]: + fragments: List[bytes] = [] + for offset in range(0, len(message), fragment_len): + fragment = message[offset : offset + fragment_len] + if len(fragment) < fragment_len: + fragment += b"\x00" * (fragment_len - len(fragment)) + fragments.append(fragment) + return fragments + + +def _mix_fragments(fragments: Sequence[bytes], indexes: Set[int], fragment_len: int) -> bytes: + result = bytearray(fragment_len) + for index in indexes: + for i, byte in enumerate(fragments[index]): + result[i] ^= byte + return bytes(result) + + +# --------------------------------------------------------------------------- # +# BC-UR v2 (bytewords-minimal + fountain) +# --------------------------------------------------------------------------- # + + +def _ur2_header(seq_num: int, seq_len: int) -> str: + return "ur:bytes/{}-{}/".format(seq_num, seq_len) + + +def _ur2_part_string(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> str: + body = cbor_part(seq_num, seq_len, message_len, checksum, data) + return _ur2_header(seq_num, seq_len) + bytewords_minimal_encode(body) + + +def _ur2_part_cost(seq_num: int, seq_len: int, message_len: int, checksum: int, data_len: int) -> int: + body_len = len(cbor_part(seq_num, seq_len, message_len, checksum, b"\x00" * data_len)) + # bytewords_minimal_encode appends a 4-byte CRC over the body. + return len(_ur2_header(seq_num, seq_len)) + 2 * (body_len + 4) + + +def ur2_frames(payload: bytes, budget_chars: int) -> List[str]: + """Encode ``payload`` into BC-UR v2 fountain frames. + + ``budget_chars`` is the largest frame string the carrying QR code may + hold. The first ``seq_len`` frames are pure (one fragment each); a second + wave of ``seq_len`` mixed (fountain) frames follows so the receiver can + recover with a few parts still missing. + """ + message = cbor_byte_string(payload) + message_len = len(message) + checksum = crc32_int(message) + single_cost = len("ur:bytes/") + len(bytewords_minimal_encode(message)) + if single_cost <= budget_chars: + return ["ur:bytes/" + bytewords_minimal_encode(message)] + + fragment_len = message_len + fragment_count = 1 + while True: + seq_len = fragment_count + worst_seq = 2 * seq_len # the export loop emits up to 2*seq_len parts + cost = _ur2_part_cost(worst_seq, seq_len, message_len, checksum, fragment_len) + if cost <= budget_chars: + break + fragment_count += 1 + fragment_len = -(-message_len // fragment_count) + if fragment_count > message_len: + raise AnimatedQrError("QR budget too small for a BC-UR v2 part") + + fragments = _partition_message(message, fragment_len) + frames: List[str] = [] + for seq_num in range(1, seq_len + 1): + frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, fragments[seq_num - 1])) + for seq_num in range(seq_len + 1, 2 * seq_len + 1): + data = _mix_fragments(fragments, choose_fragments(seq_num, seq_len, checksum), fragment_len) + frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, data)) + return frames + + +def ur2_parse_part(frame_text: str) -> Tuple[int, int, int, int, bytes]: + """Parse a BC-UR v2 part into ``(seq, seq_len, message_len, checksum, data)``.""" + frame_text = frame_text.strip().lower() + prefix = "ur:bytes/" + if not frame_text.startswith(prefix): + raise AnimatedQrError("not a BC-UR v2 part") + tail = frame_text[len(prefix) :] + if "/" not in tail: + body = bytewords_minimal_decode(tail) + return 1, 1, len(body), crc32_int(body), body + seq_head, words = tail.split("/", 1) + try: + seq_num_s, seq_len_s = seq_head.split("-", 1) + seq_num, seq_len = int(seq_num_s), int(seq_len_s) + except ValueError: + raise AnimatedQrError("bad BC-UR v2 sequence header") from None + if seq_len < 1 or not 1 <= seq_num <= 2**32 - 1: + raise AnimatedQrError("bad BC-UR v2 sequence numbers") + body = bytewords_minimal_decode(words) + arr, pos = _cbor_read_array(body, 0) + if arr != 5: + raise AnimatedQrError("bad BC-UR v2 part header arity") + seq_again, pos = _cbor_read_unsigned(body, pos) + seq_len_again, pos = _cbor_read_unsigned(body, pos) + message_len, pos = _cbor_read_unsigned(body, pos) + checksum, pos = _cbor_read_unsigned(body, pos) + data, pos = _cbor_read_bytes(body, pos) + if pos != len(body): + raise AnimatedQrError("trailing garbage in BC-UR v2 part header") + if seq_again != seq_num or seq_len_again != seq_len: + raise AnimatedQrError("BC-UR v2 part header mismatch") + return seq_num, seq_len, message_len, checksum, bytes(data) + + +# --------------------------------------------------------------------------- # +# BC-UR v1 (BCR-2020-005 rev1: BC32 fragments + SHA-256 digest) +# --------------------------------------------------------------------------- # + + +def _ur1_digest(message: bytes) -> str: + return bc32_encode(hashlib.sha256(message).digest()) + + +def _ur1_prefix(index: int, total: int, digest: str) -> str: + return "ur:bytes/{}{}/{}/".format( + index, "of{}".format(total), digest + ) + + +def ur1_frames(payload: bytes, budget_chars: int) -> List[str]: + """Encode ``payload`` into BC-UR v1 fragments (``NofM`` + BC32 + digest).""" + message = cbor_byte_string(payload) + digest = _ur1_digest(message) + full = bc32_encode(message) + + total = 1 + while True: + longest = _ur1_prefix(total, total, digest) + capacity = budget_chars - len(longest) + if capacity < 1: + raise AnimatedQrError("QR budget too small for BC-UR v1") + if len(full) <= capacity * total: + break + total += 1 + if total > _MAX_SESSION_PARTS: + raise AnimatedQrError("BC-UR v1 transfer demands too many parts") + + frames: List[str] = [] + pos = 0 + for index in range(1, total + 1): + prefix = _ur1_prefix(index, total, digest) + capacity = budget_chars - len(prefix) + frames.append(prefix + full[pos : pos + capacity]) + pos += capacity + return frames + + +def ur1_parse_part(frame_text: str) -> Tuple[int, int, str, str]: + """Parse a BC-UR v1 part into ``(index, total, digest, fragment)``. + + Accepts both the multipart form (``ur:bytes/NofM//``) and + the single-part form (``ur:bytes/``, no sequence header or digest). + """ + frame_text = frame_text.strip().lower() + prefix = "ur:bytes/" + if not frame_text.startswith(prefix): + raise AnimatedQrError("not a BC-UR v1 part") + tail = frame_text[len(prefix) :] + parts = tail.split("/") + if len(parts) == 1: + return 1, 1, "", parts[0] + if len(parts) != 3: + raise AnimatedQrError("bad BC-UR v1 part structure") + seq_head, digest, fragment = parts + if "of" not in seq_head: + raise AnimatedQrError("BC-UR v1 part misses the sequence header") + try: + index_s, total_s = seq_head.split("of", 1) + index, total = int(index_s), int(total_s) + except ValueError: + raise AnimatedQrError("bad BC-UR v1 sequence header") from None + if total < 1 or not 1 <= index <= total: + raise AnimatedQrError("bad BC-UR v1 sequence numbers") + if len(digest) != 58: + raise AnimatedQrError("bad BC-UR v1 digest") + return index, total, digest, fragment + + +# --------------------------------------------------------------------------- # +# BBQR (Coinkite) +# --------------------------------------------------------------------------- # + +_BBQR_PREFIX = "B$" + + +def _bbqr_base36(n: int) -> str: + if not 0 <= n <= 1295: + raise AnimatedQrError("BBQR part count out of range") + + def digit(x: int) -> str: + return chr(48 + x) if x < 10 else chr(65 + x - 10) + + return digit(n // 36) + digit(n % 36) + + +def _bbqr_base32(data: bytes) -> str: + return base64.b32encode(data).decode("ascii").rstrip("=") + + +def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]: + """Return ``(encoding, encoded_text, split_mod)`` honouring the reference.""" + if encoding == "H": + return "H", raw.hex().upper(), 2 + if encoding == "Z": + compressor = zlib.compressobj(wbits=-10) + compressed = compressor.compress(raw) + compressor.flush() + if len(compressed) < len(raw): + return "Z", _bbqr_base32(compressed), 8 + encoding = "2" + if encoding != "2": + raise AnimatedQrError("unknown BBQR encoding") + return "2", _bbqr_base32(raw), 8 + + +def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]: + """Encode ``payload`` into BBQR frames (``B$…``).""" + if len(type_code) != 1 or not type_code.isalnum(): + raise AnimatedQrError("bad BBQR type code") + encoding, encoded, split_mod = _bbqr_encode(payload, encoding) + chunk = budget_chars - 8 + if chunk < split_mod: + raise AnimatedQrError("QR budget too small for a BBQR frame") + chunk -= chunk % split_mod + if chunk < 1: + raise AnimatedQrError("QR budget too small for a BBQR frame") + if len(payload) > _MAX_MESSAGE_BYTES: + raise AnimatedQrError("BBQR payload exceeds the size cap") + total = -(-len(encoded) // chunk) + if total > 1295: + raise AnimatedQrError("BBQR transfer demands too many parts") + header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total) + frames: List[str] = [] + pos = 0 + for index in range(total): + frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk]) + pos += chunk + return frames + + +def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]: + """Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``.""" + frame_text = frame_text.strip() + if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX): + raise AnimatedQrError("not a BBQR frame") + encoding = frame_text[2] + type_code = frame_text[3] + if encoding not in ("H", "2", "Z"): + raise AnimatedQrError("unknown BBQR encoding") + try: + total = int(frame_text[4:6], 36) + index = int(frame_text[6:8], 36) + except ValueError: + raise AnimatedQrError("bad BBQR part numbers") from None + if total < 1 or not 0 <= index < total: + raise AnimatedQrError("bad BBQR part numbers") + if index >= _MAX_SESSION_PARTS: + raise AnimatedQrError("BBQR part number out of range") + return encoding, type_code, total, index, frame_text[8:] + + +def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes: + pieces: List[bytes] = [] + for part in encoded_parts: + if encoding == "H": + try: + pieces.append(bytes.fromhex(part)) + except ValueError: + raise AnimatedQrError("invalid BBQR hex payload") from None + continue + padding = (8 - (len(part) % 8)) % 8 + try: + pieces.append(base64.b32decode(part + "=" * padding)) + except (ValueError, TypeError): + raise AnimatedQrError("invalid BBQR base32 payload") from None + raw = b"".join(pieces) + if encoding == "Z": + try: + inflater = zlib.decompressobj(wbits=-10) + out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1) + except zlib.error: + raise AnimatedQrError("invalid BBQR zlib payload") from None + if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail: + raise AnimatedQrError("BBQR payload exceeds the size cap") + return out + return raw + + +# --------------------------------------------------------------------------- # +# Format detection & per-frame identity for the shared debounce +# --------------------------------------------------------------------------- # + +FORMAT_LABELS = { + "balqr": "BAL QR", + "ur1": "BC-UR v1", + "ur2": "BC-UR v2", + "bbqr": "BBQR", +} + + +def format_name(fmt: str) -> str: + """Human-readable name of a wire format for UI labels.""" + return FORMAT_LABELS.get(fmt, fmt) + + +def detect_format(text: str) -> Optional[str]: + """Return the wire format of a scanned string, or ``None``.""" + text = text.strip() + if not text: + return None + lowered = text.lower() + if lowered.startswith(("balqr", "bal1")): + return "balqr" + if text.startswith(_BBQR_PREFIX): + return "bbqr" + if not lowered.startswith("ur:"): + return None + if lowered.startswith("ur:bytes/"): + remainder = lowered[len("ur:bytes/") :] + first = remainder.split("/", 1)[0] + if "of" in first: + return "ur1" + if "-" in first: + return "ur2" + # Single-part: the whole remainder is the body. Prefer a bytewords v2 + # body (CBOR byte-string head 0x40..0x60), then BC32 v1. + try: + body = bytewords_minimal_decode(remainder) + except AnimatedQrError: + pass + else: + if body and 0x40 <= body[0] <= 0x60: + return "ur2" + try: + bc32_decode(remainder) + except AnimatedQrError: + return None + return "ur1" + return None + + +def parse_for_detection(text: str) -> Tuple[str, str, int, int]: + """Parse a frame and return ``(format, session_key, frame_total, index)``. + + ``session_key`` identifies the transfer the frame belongs to and drives + the shared reset/ignore/accept debounce. Raises + :class:`AnimatedQrError` when the text cannot be parsed. + """ + fmt = detect_format(text) + if fmt == "balqr": + total, index, _compressed, _payload = _parse_balqr(text) + return "balqr", "balqr:{}".format(total), total, index + if fmt == "ur1": + index, total, digest, _frag = ur1_parse_part(text) + return "ur1", "ur1:{}".format(digest), total, index + if fmt == "ur2": + seq, seq_len, message_len, checksum, _data = ur2_parse_part(text) + return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq + if fmt == "bbqr": + encoding, type_code, total, index, _payload = bbqr_parse_part(text) + return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index + raise FormatNotDetectedError("Not a supported QR transfer format") + + +def _parse_balqr(text: str) -> Tuple[int, int, bool, str]: + from bal.core.qrtransfer import parse_frame + + return parse_frame(text) + + +# --------------------------------------------------------------------------- # +# Receive sessions (order-independent assembly per format) +# --------------------------------------------------------------------------- # + +class _BalQrSession: + def __init__(self): + self._frames: Dict[int, str] = {} + self._total = 0 + self._compressed = False + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._frames) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._frames) >= self._total + + def add(self, text: str) -> str: + total, index, compressed, payload = _parse_balqr(text) + if self._total and total != self._total: + raise TransferConflictError("BAL QR transfer total changed") + if len(self._frames) >= _MAX_SESSION_PARTS: + raise SessionLimitError("too many BAL QR frames") + if not self._total: + self._total = total + self._compressed = compressed + if index in self._frames: + return "dup" + self._frames[index] = payload + return "ok" + + def resolve(self) -> Tuple[str, bool]: + from bal.core.qrtransfer import assemble + + text = assemble(self._frames, self._total) + return text, self._compressed + + +class _Ur1Session: + def __init__(self): + self._total = 0 + self._digest = "" + self._fragments: Dict[int, str] = {} + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._fragments) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._fragments) >= self._total + + def add(self, text: str) -> str: + index, total, digest, fragment = ur1_parse_part(text) + if self._total: + if total != self._total or digest != self._digest: + raise TransferConflictError("BC-UR v1 transfer digest changed") + else: + self._total = total + self._digest = digest + if total > _MAX_SESSION_PARTS: + raise SessionLimitError("BC-UR v1 demands too many parts") + if index in self._fragments: + return "dup" + self._fragments[index] = fragment + return "ok" + + def resolve(self) -> Tuple[str, bool]: + full = "".join(self._fragments[i] for i in range(1, self._total + 1)) + try: + message = bc32_decode(full) + except AnimatedQrError: + raise ChecksumError("BC-UR v1 checksum mismatch") from None + if self._digest and _ur1_digest(message) != self._digest: + raise ChecksumError("BC-UR v1 digest mismatch") + return _transfer_text(unwrap_ur_cbor(message)), False + + +class _Ur2Session: + """Fountain decoder mirroring the reference (C++/python) semantics.""" + + def __init__(self): + self._seq_len = 0 + self._message_len = 0 + self._checksum = 0 + self._fragment_len = 0 + self._received: Set[int] = set() + self._simple: Dict[FrozenSet[int], bytes] = {} + self._mixed: Dict[FrozenSet[int], bytes] = {} + self._queue: List[Tuple[FrozenSet[int], bytes]] = [] + self._processed = 0 + self._result: Optional[bytes] = None + self._bad = False + + @property + def total(self) -> int: + return self._seq_len + + @property + def received(self) -> int: + return self._processed + + @property + def done(self) -> bool: + return self._result is not None + + def add(self, text: str) -> str: + seq, seq_len, message_len, checksum, data = ur2_parse_part(text) + if self._seq_len: + if not self._validate(seq_len, message_len, checksum, len(data)): + raise TransferConflictError("BC-UR v2 transfer header changed") + else: + self._seq_len = seq_len + self._message_len = message_len + self._checksum = checksum + self._fragment_len = len(data) + if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES: + raise SessionLimitError("BC-UR v2 session exceeds safety caps") + indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum)) + self._receive(indexes, bytes(data)) + return "ok" + + def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool: + return ( + seq_len == self._seq_len + and message_len == self._message_len + and checksum == self._checksum + and data_len == self._fragment_len + ) + + def _receive(self, indexes: FrozenSet[int], data: bytes) -> None: + if self._result is not None or self._bad: + return + self._queue.append((indexes, data)) + while self._result is None and not self._bad and self._queue: + self._process(self._queue.pop(0)) + self._processed += 1 + + def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None: + indexes, data = item + if len(indexes) == 1: + self._process_simple(indexes, data) + else: + self._process_mixed(indexes, data) + + def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None: + fragment_index = next(iter(indexes)) + if fragment_index in self._received: + return + self._simple[indexes] = data + self._received.add(fragment_index) + if self._received == set(range(self._seq_len)): + self._finish() + return + self._reduce_mixed_by(indexes, data) + + def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None: + new_mixed: Dict[FrozenSet[int], bytes] = {} + for other_indexes, other_data in self._mixed.items(): + reduced = self._reduce_part(other_indexes, other_data, indexes, data) + if len(reduced[0]) == 1: + self._queue.append(reduced) + else: + new_mixed[reduced[0]] = reduced[1] + self._mixed = new_mixed + + def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None: + if indexes in self._mixed: + return + reduced_indexes, reduced_data = indexes, data + for simple_indexes, simple_data in self._simple.items(): + reduced_indexes, reduced_data = self._reduce_part( + reduced_indexes, reduced_data, simple_indexes, simple_data + ) + for other_indexes, other_data in list(self._mixed.items()): + reduced_indexes, reduced_data = self._reduce_part( + reduced_indexes, reduced_data, other_indexes, other_data + ) + if len(reduced_indexes) == 1: + self._queue.append((reduced_indexes, reduced_data)) + else: + self._reduce_mixed_by(reduced_indexes, reduced_data) + if reduced_indexes not in self._mixed: + self._mixed[reduced_indexes] = reduced_data + + @staticmethod + def _reduce_part( + a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes + ) -> Tuple[FrozenSet[int], bytes]: + if b_indexes == a_indexes or not b_indexes.issubset(a_indexes): + return a_indexes, a_data + new_indexes = a_indexes - b_indexes + new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True)) + return new_indexes, new_data + + def _finish(self) -> None: + fragments = [] + for index in range(self._seq_len): + key = frozenset([index]) + if key not in self._simple: + self._bad = True + return + fragments.append(self._simple[key]) + message = b"".join(fragments)[: self._message_len] + if crc32_int(message) != self._checksum: + self._bad = True + return + self._result = message + + def resolve(self) -> Tuple[str, bool]: + if self._bad: + raise ChecksumError("BC-UR v2 message checksum mismatch") + if self._result is None: + raise AnimatedQrError("BC-UR v2 session is not complete") + return _transfer_text(unwrap_ur_cbor(self._result)), False + + +class _BbqrSession: + def __init__(self): + self._total = 0 + self._encoding = "" + self._type_code = "" + self._parts: Dict[int, str] = {} + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._parts) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._parts) >= self._total + + def add(self, text: str) -> str: + encoding, type_code, total, index, payload = bbqr_parse_part(text) + if self._total: + if (encoding, type_code, total) != (self._encoding, self._type_code, self._total): + raise TransferConflictError("BBQR frame header changed") + else: + self._total = total + self._encoding = encoding + self._type_code = type_code + if index in self._parts: + return "dup" + self._parts[index] = payload + return "ok" + + def resolve(self) -> Tuple[str, bool]: + ordered = [self._parts[i] for i in range(self._total)] + raw = _bbqr_decode(ordered, self._encoding) + return _transfer_text(raw), False + + +def _transfer_text(raw: bytes) -> str: + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + raise AnimatedQrError("decoded transfer is not valid UTF-8") from None + + +class AnimatedQrSession: + """Facade over the per-format receive sessions used by the QR import page.""" + + _DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession")) + + def __init__(self): + self._inner: Optional[object] = None + self._fmt: Optional[str] = None + + @property + def format(self) -> Optional[str]: + return self._fmt + + def add_part(self, text: str) -> str: + """Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input.""" + fmt = detect_format(text) + if fmt is None: + raise FormatNotDetectedError("Not a supported QR transfer format") + if self._inner is None: + self._fmt = fmt + self._inner = self._make(fmt) + elif fmt != self._fmt: + raise TransferConflictError( + "Switched QR format mid-import ({} -> {})".format(self.format, fmt) + ) + return self._inner.add(text) # type: ignore[no-any-return] + + @staticmethod + def _make(fmt: str) -> object: + if fmt == "balqr": + return _BalQrSession() + if fmt == "ur1": + return _Ur1Session() + if fmt == "ur2": + return _Ur2Session() + if fmt == "bbqr": + return _BbqrSession() + raise AssertionError("unknown animated-QR format {}".format(fmt)) + + @property + def total(self) -> int: + return self._inner.total if self._inner is not None else 0 + + @property + def received(self) -> int: + return self._inner.received if self._inner is not None else 0 + + @property + def done(self) -> bool: + return bool(self._inner is not None and self._inner.done) + + def resolve(self) -> Tuple[str, bool]: + if self._inner is None: + raise AnimatedQrError("no transfer has been received") + return self._inner.resolve() # type: ignore[no-any-return] diff --git a/android/app/src/main/python/bal/core/qrtransfer.py b/android/app/src/main/python/bal/core/qrtransfer.py new file mode 100644 index 0000000..6c7d4f2 --- /dev/null +++ b/android/app/src/main/python/bal/core/qrtransfer.py @@ -0,0 +1,304 @@ +""" +bal.core.qrtransfer +=================== + +GUI-free helpers for moving BAL will data between devices via QR codes or +the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``). + +Scope +----- +* converts will transactions into a compact ``transfer_string`` + (newline-joined serialized transactions, optionally zlib + base64 + compressed); +* 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 +:func:`encode_transfer` / :func:`decode_transfer`. + +This module never imports Qt or any Electrum GUI code (house rule). +""" + +from __future__ import annotations + +import base64 +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). +CHUNK_PRESETS = ( + ("Small - ~150 bytes/QR (low-res cameras)", 150), + ("Medium - ~400 bytes/QR", 400), + ("Large - ~900 bytes/QR", 900), + ("XL - ~1800 bytes/QR (high-res cameras)", 1800), +) + +# Smallest allowed payload budget per frame, below which the frame header +# could consume the whole budget. +MIN_CHUNK_SIZE = 40 + +# 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): + """Base error for will QR / audio transfer processing.""" + + +class MissingFramesError(QrTransferError): + """Some frame indices of a multi-QR transfer are missing.""" + + def __init__(self, missing): + self.missing = list(missing) + super().__init__("Missing QR frames: {}".format(self.missing)) + + +class InconsistentTotalError(QrTransferError): + """Frames disagree about the advertised frame total.""" + + +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 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`. + + Returns the list of serialized transaction strings; empty frames are + dropped so a trailing newline (or an empty payload) cannot produce an + empty trailing element. + """ + text = __decompress(transfer_string, enabled=compressed) + return [part for part in text.split("\n") if part] + + +def split_frames(transfer_string, chunk_size, compressed=False): + """Split ``transfer_string`` into full compact ``BAL1`` frames. + + 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, or when the transfer needs more than + :data:`_MAX_TOTAL` frames. + """ + 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): + end = min(pos + budget, length) + frames.append( + _FRAME_MAGIC_V2 + + _base36(total) + + _base36(index) + + flag + + transfer_string[pos:end] + ) + pos = end + if pos < length: + # __compute_total guarantees this cannot happen; keep a safety net. + raise QrTransferError("internal error: frames did not cover the transfer string") + return frames + + +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). + """ + if frame.startswith(_FRAME_MAGIC_V2): + return _parse_v2(frame) + return _parse_v1(frame) + + +def assemble(frames, total): + """Concatenate frame payloads back into a transfer string. + + ``frames`` maps 1-based index -> payload. Every index ``1..total`` must + be present (else :class:`MissingFramesError`) and no index may exceed + ``total`` (else :class:`InconsistentTotalError`). + """ + if total < 1: + raise QrTransferError("invalid frame total") + missing = [index for index in range(1, total + 1) if index not in frames] + if missing: + raise MissingFramesError(missing) + extra = [index for index in frames if index > total] + if extra: + raise InconsistentTotalError() + return "".join(frames[index] for index in range(1, total + 1)) + + +def preset_index_for_chunk_size(chunk_size): + """Return the :data:`CHUNK_PRESETS` index whose budget best matches a size.""" + best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1]) + for index, (_label, budget) in enumerate(CHUNK_PRESETS): + diff = abs(chunk_size - budget) + if diff < best_diff: + best, best_diff = index, diff + return best + + +# --------------------------------------------------------------------------- # +# Internals +# --------------------------------------------------------------------------- # + +def __compress(text, *, enabled): + if not enabled: + return text + return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii") + + +def __decompress(text, *, enabled): + if not enabled: + return text + return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") + + +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 _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 _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 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) + ) + 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/android/app/src/main/python/balreader/__init__.py b/android/app/src/main/python/balreader/__init__.py new file mode 100644 index 0000000..ec39957 --- /dev/null +++ b/android/app/src/main/python/balreader/__init__.py @@ -0,0 +1 @@ +"""Android reader helpers built on the bundled plugin codecs.""" \ No newline at end of file diff --git a/android/app/src/main/python/balreader/bridge.py b/android/app/src/main/python/balreader/bridge.py new file mode 100644 index 0000000..2b49974 --- /dev/null +++ b/android/app/src/main/python/balreader/bridge.py @@ -0,0 +1,33 @@ +"""Kotlin-facing helper: runs the plugin's exact import tail and returns JSON. + +A serializable JSON contract keeps the Chaquopy bridge tiny on the Kotlin side +and avoids exposing ``PyObject`` tuple/container indexing to it. The steps are +the same three calls the plugin's import dialog performs: + + session.resolve() -> (transfer_text, compressed) + qrtransfer.decode_transfer -> parts + balreader.payload.decode_will_payload -> ("will"|"txs", data) +""" + +import json + +from bal.core import qrtransfer as _qrtransfer +from balreader import payload as _payload + + +def finish(session): + """Run the import tail on a live ``AnimatedQrSession``. + + Returns a JSON string ``{"kind": ..., "payload": ..., "parts": [...]}`` + with ``kind`` either ``"will"`` or ``"txs"``. On any failure it returns + ``{"kind": "error", "payload": , "parts": []}`` so a misbehaving + session can never crash the UI thread. + """ + try: + transfer, compressed = session.resolve() + parts = list(_qrtransfer.decode_transfer(transfer, compressed)) + payload = "\n".join(parts) + kind, _data = _payload.decode_will_payload(payload) + return json.dumps({"kind": kind, "payload": payload, "parts": parts}) + except Exception as exc: # noqa: BLE001 - defensive bridge boundary + return json.dumps({"kind": "error", "payload": str(exc), "parts": []}) \ No newline at end of file diff --git a/android/app/src/main/python/balreader/payload.py b/android/app/src/main/python/balreader/payload.py new file mode 100644 index 0000000..f454b1a --- /dev/null +++ b/android/app/src/main/python/balreader/payload.py @@ -0,0 +1,33 @@ +"""Will-payload autodetection for the BAL Reader app. + +This file is a verbatim copy of ``decode_will_payload`` from +``bal/gui/qt/dialogs.py``. ``android/test_chain/verify_chain.py`` compares the +two functions result-for-result so they can never drift apart. + +Keep the function body identical to the plugin source. +""" + +import json +import re +from typing import Any + + +def decode_will_payload(text) -> tuple[Any, Any]: + """Autodetect: whole-will JSON or transaction list? + + Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON + object whose values are dicts containing a ``"tx"`` key (the whole-will + format produced by :meth:`BalWindow.export_json_file` and friends). + Otherwise returns ``("txs", [tx_strings])`` where the transaction + strings were split on commas and/or newlines. + """ + text = text.strip() + try: + data = json.loads(text) + except (json.JSONDecodeError, ValueError): + data = None + if isinstance(data, dict) and data: + if all(isinstance(v, dict) and "tx" in v for v in data.values()): + return ("will", data) + parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()] + return ("txs", parts) \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..a3043c2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..f28fb91 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_result.xml b/android/app/src/main/res/layout/activity_result.xml new file mode 100644 index 0000000..0100515 --- /dev/null +++ b/android/app/src/main/res/layout/activity_result.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + +