From d288b553ef8dda40a8bc17902048ce06c2eb13ec Mon Sep 17 00:00:00 2001 From: svatantrya Date: Fri, 28 Aug 2026 16:28:37 -0400 Subject: [PATCH] core+gui: chunked multi-QR will transfer (export/import + review/sign wizard, audio channel) BALQR-framed chunk scheduler (bal/core/qrtransfer.py) with zlib compression, four chunk presets and a QR_CHUNK_SIZE setting (row 16). Export/import dialogs (WillQrExportDialog/WillQrImportDialog), per-tx review-and-sign wizard, export filters (All/Valid/Valid-NC) and an Auto slideshow with speed + loop for the QR codes; optional audio_modem channel with a local receive mirror. _prepare_and_sign_tx refactor (no behaviour change); WillItem.status defaults to '' instead of None (import crash fix); invalidate_will guard for a missing date_to_check. Docs: PLAN_QR_TRANSFER, QML_PLAN, README, HANDOFF, CHANGELOG entry 56, AUDIO_MODEM_DEBIAN. --- AUDIO_MODEM_DEBIAN.md | 170 +++++++ CHANGELOG.md | 105 +++++ HANDOFF.md | 27 ++ PLAN_QR_TRANSFER.md | 482 +++++++++++++++++++ QML_PLAN.md | 379 +++++++++++++++ README.md | 12 + bal/core/plugin_base.py | 7 + bal/core/qrtransfer.py | 212 +++++++++ bal/core/will.py | 2 +- bal/gui/qt/common.py | 2 + bal/gui/qt/dialogs.py | 840 ++++++++++++++++++++++++++++++++- bal/gui/qt/lists.py | 8 + bal/gui/qt/plugin.py | 45 ++ bal/gui/qt/window.py | 209 +++++--- tests/test_core_qr_transfer.py | 327 +++++++++++++ tests/test_gui_qr_transfer.py | 343 ++++++++++++++ 16 files changed, 3106 insertions(+), 64 deletions(-) create mode 100644 AUDIO_MODEM_DEBIAN.md create mode 100644 PLAN_QR_TRANSFER.md create mode 100644 QML_PLAN.md create mode 100644 bal/core/qrtransfer.py create mode 100644 tests/test_core_qr_transfer.py create mode 100644 tests/test_gui_qr_transfer.py diff --git a/AUDIO_MODEM_DEBIAN.md b/AUDIO_MODEM_DEBIAN.md new file mode 100644 index 0000000..8244d87 --- /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 /home/steal/devel/bal/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 /home/steal/devel/bal/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()/" \ + /home/steal/devel/bal/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 /home/steal/devel/bal/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 /home/steal/devel/bal/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 24623fd..fb054f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2908,3 +2908,108 @@ renumber the grid rows. - Full test suite: 438 passed (unchanged). **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. + +**Outcome:** DONE. diff --git a/HANDOFF.md b/HANDOFF.md index 2784bf6..f639ab0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -392,3 +392,30 @@ 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`) + +- P0–P4 of `PLAN_QR_TRANSFER.md` are implemented on + `feature/bal-qr-transfer` and un-committed: core scheduler + (`bal/core/qrtransfer.py`), settings row, export/import dialogs + + review/sign wizard + lists/window wiring, all with tests + (`tests/test_core_qr_transfer.py`, `tests/test_gui_qr_transfer.py`) and + docs (README, CHANGELOG entry 56, QML_PLAN, this file). +- Remaining: **P6** release hygiene — `python3 build_zip.py`, external-zip + test, full ruff check (no NEW violations; baseline is intentionally dirty), + and a manual on-device walkthrough of the QR path. The audio path is + optional and hidden until the `audio_modem` plugin + `amodem` are + installed. +- Commit policy unchanged: nothing is committed until the owner explicitly + confirms. + +**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..a4b1a8d --- /dev/null +++ b/PLAN_QR_TRANSFER.md @@ -0,0 +1,482 @@ +# 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 ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. | +| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. | +| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. | +| 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) + +``` +BALQR1|||| +``` + +- Magic+version literal `BALQR1` (reject anything else with a clear message; + keeps the door open for a future `BALQR2`). +- `` N, `` i — integers, `1 ≤ i ≤ N`. +- ``: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed). +- ``: the i-th slice of `transfer_string`, exactly + `chunk_size` bytes each (last slice may be shorter). +- Header overhead ≈ 16–20 bytes → effective payload = `chunk_size − overhead`; + the chunker slices the transfer string so that **header+payload ≤ preset + size**. + +### 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 full frames 'BALQR1|N|i|flags|payload'. Raises + ValueError if chunk_size < MIN_CHUNK_SIZE.""" + +def parse_frame(frame: str) -> tuple[int, int, bool, str] + """-> (total, index, compressed, payload); ValueError on bad magic/ + version/arity/non-int 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] [x Compress (zlib+base64)] +[ 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(...))`, + 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/QML_PLAN.md b/QML_PLAN.md new file mode 100644 index 0000000..be00a56 --- /dev/null +++ b/QML_PLAN.md @@ -0,0 +1,379 @@ +# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support) + +> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is +> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will +> transactions from an online machine (desktop/another phone), sign them with +> the wallet keys held on it, and return the signed transactions — a classic +> air-gapped signer workflow. Online features (willexecutor contact, +> broadcast, build) are secondary on Android and belong mainly to the online +> machine. +> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic +> through the existing GUI-free `BalController`, exactly like `bal/cli/` +> already does. The PyQt6 desktop GUI remains untouched and primary. +> +> Status: DRAFT for owner review (rule R4 — no code until explicit OK). +> Chat language: Italian; this document is in English per rule R1. + +--- + +## 1. Verified facts (research done on the local checkouts) + +All items below were verified by reading source, not assumed. + +| # | Fact | Where | +|---|------|-------| +| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` | +| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` | +| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `//qml/.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` | +| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `/home/steal/devel/bal/electrum470/electrum/gui/{qml,common_qt}` | +| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 | +| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` | +| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` | +| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` | +| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` | +| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 | +| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` | + +--- + +## 2. Scope + +### In scope (MVP) + +Two usage **profiles** share one codebase: + +- **Offline signer profile** (Android, PRIORITY): works with no network. + 1. Import a will bundle exported by the online machine (existing JSON will + format, see F12) — via file share, paste, or QR. + 2. Review what is being signed (destinations, amounts, locktimes, fees). + 3. Sign internally (`wallet.sign_transaction` + password prompt). + Partial signatures are combined when the bundle is re-imported + (`combine_with_other_psbt`, already supported by `merge_will` logic). + 4. Export the signed bundle back to the online machine. +- **Online manager profile** (desktop QML, secondary): + - Will status overview (state, expiry/check-alive date, reminder info). + - Heirs list (view/add/edit/remove, addresses or URIs). + - Will-executor selection (list, enable/disable, fee display, refresh). + - Build will (simplified wizard reusing core validation). + - Sign in place (password) or hand off to an offline signer via bundles. + - Broadcast / push to will-executors; invalidate will; check-alive refresh. + - Basic settings mapped onto `will_settings`. + +The offline signer pages are built first and must function with the network +disabled (Electrum runs fine offline; willexecutor refresh simply degrades). + +Also in scope: + +- Android packaging: BAL bundled as internal plugin in the custom APK, + **enabled by default** (owner decision D3). +- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged. + +### Out of scope (explicitly deferred) + +- Full parity with the PyQt6 GUI (calendar widget, preview list editor, + advanced fee controls, themes). +- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP + builds keep working for desktop exactly as today, without `qml` UI). +- iOS, upstream-Electrum (spesmilo) compatibility. +- Lightning-related features (irrelevant to BAL). + +--- + +## 3. Architecture + +``` + ┌───────────────────────────────────────────┐ + │ bal/core │ + │ heirs, will, willexecutors, checkalive, │ + │ reminders, input_rules, plugin_base │ + └────────────┬──────────────────────────────┘ + │ (no Qt anywhere) + ┌─────────────────────┼──────────────────────┐ + ▼ ▼ ▼ + bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW + BalWindow etc. BalController qml_plugin.py (BalQmlPlugin) + (~9.3k lines) (headless flows) models.py (QObject VMs) + so.py (PluginQObject) + *.qml (views) + ▲ + wraps ONE BalController + per loaded wallet +``` + +Design rules: + +- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject + wrappers around one `BalController` instance per wallet. No business logic + in QML or in the wrappers beyond formatting. +- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`) + are registered by `bal/core/plugin_base.py` already; QML reads/writes them + through the controller, so a wallet moves between desktop/Android unchanged. +- **Threading.** Network operations (willexecutor fetch/push, broadcast) + run in worker threads exactly as the CLI does; results marshalled to the UI + thread via Qt signals on the wrapper objects. No blocking calls in slots. +- **One transfer format.** The airgap round trip reuses the existing JSON + will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's + `export_json_file`/`import` + `merge_will` flow already produces and + consumes — see F12). No new format is invented; export/merge logic gets a + single shared home usable by both frontends. +- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and + defensively; if absent (e.g., odd build), the plugin degrades to core-only + behavior instead of crashing the daemon. + +--- + +## 4. Work breakdown + +### Phase 0 — Verification spikes (no product code) + +| Task | Description | Exit criterion | +|------|-------------|----------------| +| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section | +| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash | +| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` | +| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file | + +Deliverable: short findings report appended to this document; go/no-go. + +### Phase 1 — Skeleton integration + +Files (all NEW unless noted): + +``` +bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py +bal/gui/qml/__init__.py package docstring +bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase) +bal/gui/qml/so.py class BalSignalObject(PluginQObject) +bal/manifest.json MODIFIED: available_for += ["qml"] +``` + +Details: + +- `qml_plugin.py`: + - `@hook init_qml(self, app)`: store app ref; create `so` parented to app; + for each already-loaded wallet call `_on_wallet_loaded(wallet)` + (mirrors labels' pattern, see F3). + - `@hook load_wallet(self, wallet)` — **single argument** (F9); creates the + per-wallet view-model bundle (Phase 2) keyed by `wallet`. + - `@hook unload_wallet(self, wallet)`: drop controllers, close windows. +- `so.py`: `BalSignalObject(PluginQObject)` exposing: + - `loader` property returning `"BalMain.qml"` (drives F3 auto-create); + - signals: `walletChanged`, `willStateChanged`, `heirsChanged`, + `willexecutorsChanged`, `busyChanged`; + - slots called from QML: open/close window, refresh willexecutors, + check-alive now, build/sign/broadcast/invalidate commands. +- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched + (Electrum filters per running GUI, verified F2). + +Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created +(log line from `onPluginLoaded`), no functional UI yet. + +### Phase 2 — View-models (QObject layer) + +New file `bal/gui/qml/models.py`: + +- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only + properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`, + `sigsHave/sigsRequired` per tx) + notification signals; forwards actions to + controller methods (`build_will`, `sign_transactions`, `broadcast_will`, + `invalidate_will_headless`, `check_alive`... — names per controller). + - **Airgap methods (priority):** `export_will_bundle()` and + `import_will_bundle(json_text)` returning summary of what changed. These + are small ports of the Qt GUI's `export_json_file` (window.py:1605) and + `merge_will` (window.py:1620) semantics. Preferred implementation: move + the logic into shared helpers (controller level or `bal/core/will.py` + static functions) and make the Qt GUI call the same helpers, so the two + frontends cannot diverge; regression-covered by existing core tests plus + new round-trip tests. + - Offline profile detection: expose an `isOffline` property derived from + `wallet.network is None` / config, so QML can hide online-only pages. +- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`, + `valid`; edit methods delegate to `Heirs` helpers through controller. +- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`, + `sigsHave`, `sigsRequired`, `isComplete`. +- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`, + `fee`, `valid`; toggle + async refresh. +- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer + planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt + plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL / + pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets; + re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR + rendering inside the model (QML paints it). +- All list mutations happen on the controller state then `beginResetModel/ + endResetModel` (datasets are small; simplicity over incremental updates). + +Exit criterion: pytest-driven model tests pass offscreen (create models over a +regtest/testnet wallet fixture, assert roles after mutations). + +### Phase 3 — QML views + +New directory `bal/gui/qml/components/`: + +``` +BalMain.qml top-level Window; stack of pages below +BalSignPage.qml PRIORITY (offline signer): import bundle + (paste / file / QR), review summary of each tx, + password-sign, export signed bundle back +BalQrExportPage.qml QR export view: drives BalQrTransferModel, one + frame at a time (Prev/Next, progress i/N, chunk + preset selector), mirror of desktop Qt dialog +BalQrImportPage.qml QR import view: slot grid (1..N), camera via + Electrum `QRScan` reuse, manual paste fallback, + then jump into BalSignPage review/sign +BalStatusPage.qml will state, expiry countdown, check-alive button + (works offline with last-known data) +BalHeirsPage.qml ListView + add/edit dialog [online profile] +BalExecutorsPage.qml ListView with switches + refresh [online profile] +BalBuildPage.qml simplified build form (threshold selector, fees, + executor pick) → runs controller.build_will + [online profile] +BalSettingsPage.qml maps onto will_settings subset +controls/BalButton.qml, BalField.qml minimal styled primitives +qmldir module registration +``` + +Notes: + +- **BalSignPage review screen is mandatory**: before signing, the user must + see per-transaction heir address, amount, locktime (date), and fee — this + device is the security boundary, so no "blind signing". +- QR transfer: a full will bundle may exceed one QR's capacity when there are + many heirs/transactions. The desktop Qt plugin now resolves this with + chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets + 150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same + scheduler via a thin model. Order of preference stays (1) share/save file + + paste text; (2) chunked QR streams through `BalQrTransferModel`. +- Styling minimal, follow existing QML components' look (reuse + `controls/` from Electrum where importable — prefer copying tiny primitives + to avoid coupling to upstream churn; decide during implementation). +- Every string wrapped with `electrum.i18n._`. + +Exit criterion: full manual walkthrough on desktop QML GUI (offscreen + +interactive) performing BOTH: (a) online profile — configure heirs → select +executors → build → sign → broadcast → invalidate; and (b) offline signer +profile — export unsigned bundle from an online wallet, import into a second +offline wallet instance, sign, re-export, import signed bundle back into the +first wallet and verify signatures combined/status COMPLETE. Walkthrough (b) +must pass with networking disabled. + +### Phase 4 — Android integration + +- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to + `contrib/android/buildozer_qml.spec` in the Electrum fork. +- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)** + and default-enable BAL in the APK build (D3: enabled by default). +- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1 + approved). Fallback if T4 picks auto-open: window opens on wallet load. +- Rebuild APK; smoke-test on device/emulator: + install → BAL already enabled → open wallet → full MVP walkthrough, + including airplane-mode signing round trip (file/QR transfer between an + online desktop and the offline device — for emulator testing, "offline" = + network disabled via settings). +- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators — + already house style), background network on mobile (willexecutor timeouts), + APK size impact (bal is small; icons only), share-intent/file access + permissions for bundle import/export. + +Exit criterion: signed test APK passes the same walkthrough as Phase 3. + +### Phase 5 — Tests & CI hygiene + +- New tests following repo conventions (`def test_*` + `__main__` block): + - `tests/test_qml_models.py` — models over fake/controller-backed wallet + (offscreen, no network). + - `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle + from wallet A → import into wallet B (same seed, offline) → sign → + export signed → merge back into A; assert COMPLETE status and signature + counts. Must pass with no network. + - `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed + QML app object; `so` wiring correct; load/unload wallet lifecycle. + - Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3 + tests/smoke_test.py electrum.plugins.bal` still green (qt path intact). +- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW + violations) before any delivery ZIP. +- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml + offscreen, Android APK] × [4.7.2, 4.8.0] where applicable. + +### Phase 6 — Release plumbing & docs + +- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included + in deterministic zip (harmless on desktop; enables future extraction-based + loading). +- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status, + limitations, and how to enable (`-g qml` / APK toggle). +- `CHANGELOG.md`: numbered entry at END per house rules. +- Version bump + release handled by `make-release.sh` as usual (owner-driven). + +--- + +## 5. Risks & mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed | +| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop | +| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting | +| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI | +| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable | +| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 | +| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test | +| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` | +| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) | +| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework | + +--- + +## 6. Questions for the owner — ANSWERED (2026-08-25) + +1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's + `main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle. +2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.** + The phone holds the keys and acts as air-gapped signer: import unsigned + bundle → review → sign → export signed bundle back to the online machine. + The sign/import/export page is therefore the top priority of Phase 3, and + the round-trip test is the top priority of Phase 5. +3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK** + (toggle still available to disable). +4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get + back later. Not blocking: see risk R10 mitigation. + +--- + +## 7. Effort estimate + +| Phase | Rough size | +|-------|-----------| +| 0 spikes | ~half day (mostly reading + one throwaway branch) | +| 1 skeleton | ~300 lines Python | +| 2 models (incl. shared export/merge helpers) | ~600–800 lines Python | +| 3 views (offline signer page first) | ~900–1300 lines QML | +| 4 android | fork-side patches + build iteration (device-dependent) | +| 5 tests | ~500 lines | +| 6 release/docs | small | + +Overall: comparable to a medium feature, dominated by Phase 3 UI polish and +Phase 4 device iteration. + +--- + +## 8. Findings log (append-only) + +- **F12** — The airgap round trip already exists in the Qt GUI layer and can + be ported almost verbatim: `export_json_file()` (window.py:1605) exports + all will items as `{wid: WillItem.to_dict()}` JSON (marking them + `EXPORTED`); the import side goes through `merge_will()` + (window.py:1620), which carries operational statuses, combines partial + signatures via `tx.combine_with_other_psbt()` when txids match, substitutes + the tx otherwise, and recomputes validity locally without network. + Conclusion: no new transfer format is needed; the plan is to give this + logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML + all use one implementation. + +### Decisions + +- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by + the owner. +- **D2** — Android = offline signing device; sign/import/export flow has + top priority. +- **D3** — BAL enabled by default in the custom APK. +- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10). diff --git a/README.md b/README.md index ca3f196..c9bb548 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ bal/ the installable Electrum plugin package │ ├── willexecutors.py │ ├── checkalive.py │ ├── reminders.py +│ ├── qrtransfer.py QR will-transfer wire format / chunk scheduler │ └── input_rules.py ├── cli/ headless command-line layer (no Qt) │ ├── commands.py bal_* daemon commands (@plugin_command) @@ -87,6 +88,17 @@ 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); 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 full wire-format spec. + ## Command-line / headless usage BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 188e73e..c0fbe55 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -279,6 +279,13 @@ class BalPlugin(BasePlugin): # stay display-only outside the wizard unless the user opts in. self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) + # QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used + # per QR frame when exporting/importing a will through the QR channel. + # The settings dialog offers the 4 standard presets of + # bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget. + # Default 150 (small QR, low-resolution cameras). + self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) + # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the # exported .ics calendar should contain. Each reminder becomes its own # VEVENT (its own date in the calendar). The dates are spread uniformly diff --git a/bal/core/qrtransfer.py b/bal/core/qrtransfer.py new file mode 100644 index 0000000..e131203 --- /dev/null +++ b/bal/core/qrtransfer.py @@ -0,0 +1,212 @@ +""" +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 ``BALQR1|N|i|flags|payload`` frames for + multi-QR export, and reassembles/validates them 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" + +# 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 + +_FRAME_MAGIC = MAGIC + str(VERSION) + + +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 flags + of the frame header let the importer reverse this automatically. + """ + return __compress("\n".join(tx_strings), enabled=compress) + + +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 ``BALQR`` frames. + + Every returned frame is at most ``chunk_size`` characters long (header + included). ``compressed`` propagates the ``Z`` flag into every frame so + the importer knows how to reverse the encoding. + + Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold + the header plus any payload. + """ + flags = FLAG_COMPRESSED if compressed else "" + total = __compute_total(len(transfer_string), chunk_size, flags) + frames = [] + pos = 0 + length = len(transfer_string) + for index in range(1, total + 1): + overhead = len(__frame_header(total, index, flags)) + budget = chunk_size - overhead + end = min(pos + budget, length) + frames.append(__build_frame(total, index, flags, transfer_string[pos:end])) + pos = end + if pos >= length: + break + if pos < length: + # __compute_total guarantees this cannot happen; keep a safety net. + raise QrTransferError("internal error: frames did not cover the transfer string") + return frames + + +def parse_frame(frame): + """Parse a single frame. + + Returns ``(total, index, compressed: bool, payload: str)``. Raises + :class:`QrTransferError` on malformed input (bad magic/version, wrong + arity, non-integer or out-of-range frame numbers, unknown flags). + """ + parts = frame.split("|", maxsplit=4) + if len(parts) != 5: + raise QrTransferError("Not a BAL will QR (bad frame structure)") + magic_seen, total_s, index_s, flags, payload = parts + if magic_seen != _FRAME_MAGIC: + raise QrTransferError("Not a BAL will QR (unknown magic/version)") + try: + total = int(total_s) + index = int(index_s) + except ValueError as e: + raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e + if total < 1 or not 1 <= index <= total: + raise QrTransferError("Not a BAL will QR (frame numbering out of range)") + if flags not in ("", FLAG_COMPRESSED): + raise QrTransferError("Not a BAL will QR (unknown flags)") + return total, index, flags == FLAG_COMPRESSED, payload + + +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 __frame_header(total, index, flags): + return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags) + + +def __build_frame(total, index, flags, payload): + return __frame_header(total, index, flags) + payload + + +def __compute_total(transfer_len, chunk_size, flags): + """Smallest frame count whose budget covers the whole transfer string. + + The budget shrinks as ``total`` gains digits (wider header), so the count + is recomputed iteratively until it converges. + """ + if chunk_size < MIN_CHUNK_SIZE: + raise QrTransferError( + "chunk size too small to hold a BAL QR frame: {}".format(chunk_size) + ) + total = 1 + while True: + overhead = len(__frame_header(total, total, flags)) + budget = chunk_size - overhead + if budget <= 0: + raise QrTransferError( + "chunk size too small for the BAL QR frame header: {}".format(chunk_size) + ) + if transfer_len <= budget * total: + return total + total += 1 \ No newline at end of file diff --git a/bal/core/will.py b/bal/core/will.py index 4122b26..bdd7cfc 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -1338,7 +1338,7 @@ class WillItem(Logger): self.tx = Will.get_tx_from_any(w["tx"]) self.heirs = w.get("heirs", None) self.we = w.get("willexecutor", None) - self.status = w.get("status", None) + self.status = w.get("status") or "" self.description = w.get("description", None) self.time = w.get("time", None) self.change = w.get("change", None) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 3ce13ca..a680482 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -28,6 +28,7 @@ from functools import partial from typing import Any, Callable, Mapping, Optional, Union from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN +from electrum.gui.common_qt.util import draw_qr from electrum.gui.qt.amountedit import BTCAmountEdit from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton from electrum.gui.qt.my_treeview import MyTreeView @@ -42,6 +43,7 @@ from electrum.gui.qt.util import ( MessageBoxMixin, OkButton, TaskThread, + WaitingDialog, WindowModalDialog, char_width_in_lineedit, getOpenFileName, diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 23e514d..73bca32 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -11,16 +11,35 @@ All modal/non-modal dialogs of the plugin. * BalBuildWillDialog - the central build/sign/push/broadcast flow. * WillDetailDialog - shows the full will tree for one wallet. * WillExecutorDialog - manage the list of will-executor servers. + * WillQrExportDialog - export the will as a sequence of QR codes. + * WillQrImportDialog - capture/assemble a will from QR shots (or + audio) and send it to the review+sign wizard. + * WillTxReviewSignDialog - per-transaction review/sign wizard for the + imported will (external copy, never touches the live will). To keep the dialogs verbatim while avoiding import cycles with the list views, the few list classes they reference are imported lazily inside the methods that use them (see ``lists`` imports below). """ +import io +import zlib + from typing import TYPE_CHECKING -from ...core.checkalive import CheckAliveError +from ...core.checkalive import CheckAliveError, resolve_date_to_check from ...core.reminders import build_ics_reminders +from ...core.qrtransfer import ( + CHUNK_PRESETS, + MissingFramesError, + QrTransferError, + assemble, + decode_transfer, + encode_transfer, + parse_frame, + preset_index_for_chunk_size, + split_frames, +) from .calendar import BalCalendarButton from .common import ( _, @@ -42,13 +61,18 @@ from .common import ( NoHeirsException, NoWillExecutorNotPresent, NotCompleteWillException, + QCheckBox, QComboBox, QDialog, + QGridLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QScrollArea, QSizePolicy, + QSpinBox, + QStackedWidget, QTimer, QVBoxLayout, QWidget, @@ -57,16 +81,21 @@ from .common import ( TxBroadcastError, TxFeesChangedException, Util, + WaitingDialog, Will, WillExecutorFeeTooHighException, WillExecutorNotPresent, WillExpiredException, + WillItem, WillPostponedException, WillexecutorChangeException, Willexecutors, bring_to_front, decimal_point_to_base_unit_name, + draw_qr, + export_meta_gui, import_meta_gui, + log_error, partial, pyqtSignal, read_QIcon_from_bytes, @@ -76,6 +105,7 @@ from .common import ( stop_thread, time, top_level_of, + write_json_file, ) from .widgets import ( WillSettingsWidget, @@ -2363,3 +2393,811 @@ class HeirsDialog(BalDialog, MessageBoxMixin): def closeEvent(self, event): event.accept() + +# --------------------------------------------------------------------------- # +# QR / audio will transfer +# --------------------------------------------------------------------------- # + +class BalQrImage(QWidget): + """A widget that renders one QR code, scaled to its own size. + + Electrum's own ``QRCodeWidget`` hard-codes the LOW error-correction level, + which is fine for a one-shot payload but risky for long multi-frame will + transfers. This widget renders a fresh code on every paint with MEDIUM + correction using Electrum's :func:`draw_qr` paint helper. + """ + + def __init__(self, text="", parent=None): + QWidget.__init__(self, parent) + self.text = text + self.setMinimumSize(240, 240) + self.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + + def set_text(self, text): + self.text = text + self.update() + + def paintEvent(self, event): + # Imported lazily: qrcode is shipped with Electrum but it is not a Qt + # widget, so keeping it out of the hub import keeps dialogs importable + # even when qrcode itself is missing at import time. + import qrcode + + qr = qrcode.QRCode( + error_correction=qrcode.ERROR_CORRECT_M, border=2 + ) + qr.add_data(self.text) + qr.make(fit=True) + draw_qr( + qr=qr, paint_device=self, is_enabled=True, min_boxsize=2 + ) + QWidget.paintEvent(self, event) + + +class WillQrExportDialog(BalDialog): + """Export a will as a sequence of QR codes, one per screen. + + The chosen transactions are serialized with the wire format of + :mod:`bal.core.qrtransfer` and split into frames of the configured chunk + size. The user walks the frames with Prev/Next arrows; the chunk size can + be changed live from the standard presets. An "Audio" send button is shown + when Electrum's ``audio_modem`` plugin is available (raw newline-joined + transactions, no BAL framing, since the audio transport zlib-compresses + internally). + """ + + def __init__(self, bal_window, will=None, bal_plugin=None): + BalDialog.__init__( + self, bal_window.window, bal_plugin, _("Export will via QR codes") + ) + self.bal_window = bal_window + try: + chunk = int(bal_window.bal_plugin.QR_CHUNK_SIZE.get()) + except Exception: + chunk = CHUNK_PRESETS[0][1] + self.chunk_size = chunk + self._source = will if will is not None else bal_window.willitems + # Export filters: which will items the QR transfer includes. Mirrors + # the All / Valid / Valid-NC choices of the "Export" file menu. + self._filters = [ + (_("All"), lambda wi: True), + (_("Valid"), lambda wi: wi.get_status("VALID")), + ( + _("Valid NC"), + lambda wi: wi.get_status("VALID") and not wi.get_status("COMPLETE"), + ), + ] + self._filter_index = 0 + self.auto_timer = QTimer(self) + self.auto_timer.timeout.connect(self._auto_step) + self._build_transfer(self._filtered_willitems()) + if not self.tx_strings: + self.show_message(_("No will transaction to export.")) + self.close() + return + self.index = 0 + vbox = QVBoxLayout(self) + + self.intro_label = QLabel() + self._update_intro() + vbox.addWidget(self.intro_label) + + filter_row = QHBoxLayout() + filter_row.addWidget(QLabel(_("Export:"))) + self.filter_combo = QComboBox() + self.filter_combo.addItems([label for label, _fn in self._filters]) + self.filter_combo.currentIndexChanged.connect(self._on_filter_change) + filter_row.addWidget(self.filter_combo) + filter_row.addStretch(1) + vbox.addLayout(filter_row) + + self.qr_view = BalQrImage(parent=self) + self.qr_view.set_text(self.frames[self.index]) + vbox.addWidget(self.qr_view) + + self.progress_label = QLabel() + vbox.addWidget(self.progress_label) + + nav = QHBoxLayout() + self.prev_btn = QPushButton(_("Previous")) + self.prev_btn.clicked.connect(self._prev) + nav.addWidget(self.prev_btn) + self.next_btn = QPushButton(_("Next")) + self.next_btn.clicked.connect(self._next) + nav.addWidget(self.next_btn) + nav.addStretch(1) + + if bal_window.get_audio_modem_plugin() is not None: + audio_btn = QPushButton(_("Audio…")) + audio_btn.setToolTip( + _("Send the export over your speaker (Audio MODEM plugin).") + ) + audio_btn.clicked.connect(self._audio_send) + nav.addWidget(audio_btn) + + vbox.addLayout(nav) + + auto_row = QHBoxLayout() + self.auto_btn = QPushButton(_("Auto")) + self.auto_btn.setToolTip( + _("Automatically advance through the QR codes.") + ) + self.auto_btn.clicked.connect(self._toggle_auto) + auto_row.addWidget(self.auto_btn) + auto_row.addWidget(QLabel(_("QR codes per second:"))) + self.fps_spin = QSpinBox() + self.fps_spin.setRange(1, 10) + self.fps_spin.setValue(1) + self.fps_spin.setSuffix(_(" /s")) + auto_row.addWidget(self.fps_spin) + self.loop_check = QCheckBox(_("Loop")) + self.loop_check.setToolTip( + _("When the last QR code is reached, keep cycling from the " + "first one instead of stopping.") + ) + auto_row.addWidget(self.loop_check) + auto_row.addStretch(1) + vbox.addLayout(auto_row) + + size_row = QHBoxLayout() + size_row.addWidget(QLabel(_("QR code size:"))) + self.size_combo = QComboBox() + self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS]) + self.size_combo.setCurrentIndex( + preset_index_for_chunk_size(self.chunk_size) + ) + self.size_combo.currentIndexChanged.connect(self._on_chunk_change) + size_row.addWidget(self.size_combo) + size_row.addStretch(1) + vbox.addLayout(size_row) + + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + + self._render() + + def _build_transfer(self, willitems): + """Serialize the chosen transactions into frame-ready state.""" + items = sorted(willitems.values(), key=lambda wi: str(wi.tx.txid())) + self.tx_strings = [str(wi.tx) for wi in items] + self.audio_payload = "\n".join(self.tx_strings) + self.transfer = encode_transfer(self.tx_strings, compress=False) + self._refresh_frames() + self.total_frames = len(self.frames) + + def _refresh_frames(self): + self.frames = split_frames( + self.transfer, self.chunk_size, compressed=False + ) + self.index = 0 + + def _on_chunk_change(self, index): + self._stop_auto() + self.chunk_size = CHUNK_PRESETS[index][1] + self._refresh_frames() + self._render() + + def _toggle_auto(self): + """Start/stop the automatic QR slideshow.""" + if self.auto_timer.isActive(): + self._stop_auto() + return + if len(self.frames) <= 1: + return + fps = self.fps_spin.value() + if fps <= 0: + return + self.auto_btn.setText(_("Stop")) + self.auto_timer.start(int(1000 / fps)) + + def _stop_auto(self): + if self.auto_timer.isActive(): + self.auto_timer.stop() + self.auto_btn.setText(_("Auto")) + + def _auto_step(self): + if self.index >= len(self.frames) - 1: + # Reach the last code: loop back or stop the slideshow. + if self.loop_check.isChecked(): + self.index = 0 + self._render() + return + self._stop_auto() + return + self._next() + + def _filtered_willitems(self): + """The will items selected by the current export filter.""" + _label, fn = self._filters[self._filter_index] + return {wid: wi for wid, wi in self._source.items() if fn(wi)} + + def _update_intro(self): + self.intro_label.setText( + _( + "Scan the QR codes below, in order, with the will-opening " + "device.\nFrame 1 of {} carries the total number of codes." + ).format(self.total_frames) + ) + + def _on_filter_change(self, index): + previous = self._filter_index + self._filter_index = index + self._stop_auto() + if not self._filtered_willitems(): + # The selection is empty under the new filter: revert and inform. + self.filter_combo.blockSignals(True) + self.filter_combo.setCurrentIndex(previous) + self.filter_combo.blockSignals(False) + self._filter_index = previous + self.show_message(_("No will transaction matches the selected filter.")) + return + self._build_transfer(self._filtered_willitems()) + self._update_intro() + self._render() + + def _prev(self): + if self.index > 0: + self.index -= 1 + self._render() + + def _next(self): + if self.index < len(self.frames) - 1: + self.index += 1 + self._render() + + def _render(self): + self.qr_view.set_text(self.frames[self.index]) + self.progress_label.setText( + _("Frame {} of {}").format(self.index + 1, len(self.frames)) + ) + self.prev_btn.setEnabled(self.index > 0) + self.next_btn.setEnabled(self.index < len(self.frames) - 1) + + def _audio_send(self): + try: + self.bal_window._audio_send_payload(self.audio_payload) + except Exception as e: + log_error(e, self) + self.show_error(str(e)) + + +class WillQrImportDialog(BalDialog): + """Import a will by scanning its QR codes (or receiving it by audio). + + Frames are captured one by one from the camera (or typed manually). The + first frame fixes the total frame count and the transfer compression flag; + the slot grid shows which frames are still missing. When every frame is + present the "Review and Sign" button assembles the transfer, decodes it + into transactions and hands them to :class:`WillTxReviewSignDialog`. The + audio path is one-shot (no BAL framing) and jumps straight to the wizard. + All work happens on a local copy; the live will is never touched. + """ + + def __init__(self, bal_window, bal_plugin=None): + BalDialog.__init__( + self, bal_window.window, bal_plugin, _("Import will via QR codes") + ) + self.bal_window = bal_window + self.frames = {} + self.total = 0 + self.compressed = False + self._scanning = False + self.slot_widgets = {} + + vbox = QVBoxLayout(self) + intro = QLabel( + _( + "Scan the QR codes printed by the will-opening device, one " + "shot at a time.\nDuplicates are ignored; the first frame " + "sets the total number of codes." + ) + ) + intro.setWordWrap(True) + vbox.addWidget(intro) + + self.status_label = QLabel(_("Waiting for the first frame…")) + vbox.addWidget(self.status_label) + + # Slot grid inside a scroll area (a large will can need many frames). + self.slot_widget = QWidget() + self.slot_grid = QGridLayout(self.slot_widget) + self.slot_grid.setSpacing(4) + self.slot_area = QScrollArea() + self.slot_area.setWidget(self.slot_widget) + self.slot_area.setWidgetResizable(True) + self.slot_area.setMaximumHeight(180) + self.slot_area.setVisible(False) + vbox.addWidget(self.slot_area) + + manual = QHBoxLayout() + self.manual_edit = QLineEdit() + self.manual_edit.setPlaceholderText( + _("…or paste/type the frame text here") + ) + self.manual_edit.returnPressed.connect(self._add_from_manual) + manual.addWidget(self.manual_edit) + manual_btn = QPushButton(_("Add frame")) + manual_btn.clicked.connect(self._add_from_manual) + manual.addWidget(manual_btn) + vbox.addLayout(manual) + + buttons = QHBoxLayout() + self.scan_btn = QPushButton(_("Scan QR with camera")) + self.scan_btn.clicked.connect(self._scan_camera) + buttons.addWidget(self.scan_btn) + if bal_window.get_audio_modem_plugin() is not None: + audio_btn = QPushButton(_("Receive by audio…")) + audio_btn.clicked.connect(self._audio_receive) + buttons.addWidget(audio_btn) + self.reset_btn = QPushButton(_("Reset")) + self.reset_btn.clicked.connect(self._reset_all) + buttons.addWidget(self.reset_btn) + buttons.addStretch(1) + vbox.addLayout(buttons) + + bottom = QHBoxLayout() + self.review_btn = QPushButton(_("Review and Sign…")) + self.review_btn.setEnabled(False) + self.review_btn.clicked.connect(self._review_and_sign) + bottom.addWidget(self.review_btn) + bottom.addStretch(1) + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + bottom.addWidget(close_btn) + vbox.addLayout(bottom) + + # -- frame handling ------------------------------------------------------- + + def _add_from_manual(self): + text = self.manual_edit.text().strip() + if text: + self.manual_edit.clear() + self._add_frame(text) + + def _add_frame(self, frame_text): + try: + total, index, compressed, payload = parse_frame(frame_text) + except QrTransferError as e: + self.show_error(str(e)) + return + if self.total and total != self.total: + # A different total means a different transfer: wipe and restart. + self._reset_all() + self.show_warning( + _( + "The scanned code belongs to a different transfer ({} " + "frames). The import was reset; scan the first code again." + ).format(total) + ) + return + if not self.total: + self.total = total + self.compressed = compressed + self._init_slots() + self.frames[index] = payload + self._update_slots() + self._update_status() + + def _init_slots(self): + while self.slot_grid.count(): + item = self.slot_grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + self.slot_grid.removeItem(item) + self.slot_widgets = {} + for index in range(1, self.total + 1): + b = QPushButton(str(index)) + b.setEnabled(False) + row = (index - 1) // 8 + col = (index - 1) % 8 + self.slot_grid.addWidget(b, row, col) + self.slot_widgets[index] = b + self.slot_area.setVisible(True) + + def _update_slots(self): + for index, b in self.slot_widgets.items(): + present = index in self.frames + b.setStyleSheet( + "QPushButton{background-color:#90ee90;}" if present else "" + ) + + def _update_status(self): + have = len(self.frames) + if have >= self.total: + self.status_label.setText(_("All {} frames stored.").format(self.total)) + self.review_btn.setEnabled(True) + else: + self.status_label.setText( + _("Stored {} of {} frames.").format(have, self.total) + ) + self.review_btn.setEnabled(False) + + def _reset_all(self): + self.frames = {} + self.total = 0 + self.compressed = False + if self.slot_widgets: + for b in self.slot_widgets.values(): + b.deleteLater() + self.slot_widgets = {} + self.slot_area.setVisible(False) + self.review_btn.setEnabled(False) + self.status_label.setText(_("Waiting for the first frame…")) + + # -- capture -------------------------------------------------------------- + + def _scan_camera(self): + if self._scanning: + return + from electrum.gui.qt.qrreader import scan_qrcode_from_camera + + self._scanning = True + self.scan_btn.setEnabled(False) + + def callback(success, error, data): + self._scanning = False + self.scan_btn.setEnabled(True) + if success and data: + self._add_frame(data) + elif not success and error: + self.show_error(str(error)) + + try: + scan_qrcode_from_camera( + parent=self, + config=self.bal_window.window.config, + callback=callback, + ) + except Exception as e: + self._scanning = False + self.scan_btn.setEnabled(True) + self.show_error(str(e)) + + def _audio_receive(self): + """Receive a transfer over the audio_modem plugin (raw tx list). + + The audio transport compresses internally and carries no BAL framing, + so this mirrors the plugin's own ``_recv`` (which hard-wires a + ``setText`` sink) but delivers the decoded text through a callback. + """ + plugin = self.bal_window.get_audio_modem_plugin() + if plugin is None: + self.show_error(_("Audio MODEM plugin is not available.")) + return + try: + import amodem # noqa: F401 # type: ignore (guaranteed by is_available) + except Exception as e: + self.show_error(str(e)) + return + + def receiver_thread(): + with plugin._audio_interface() as interface: + src = interface.recorder() + dst = io.BytesIO() + amodem.main.recv(config=plugin.modem_config, src=src, dst=dst) + return dst.getvalue() + + def on_success(blob): + if not blob: + return + try: + text = zlib.decompress(blob).decode("ascii") + except Exception as e: + self.show_error(str(e)) + return + tx_strings = [ + part for part in text.split("\n") if part and part.strip() + ] + if not tx_strings: + self.show_error(_("No transaction data received.")) + return + self._finish_import(tx_strings) + + kbps = plugin.modem_config.modem_bps / 1e3 + WaitingDialog( + self, + _("Waiting for audio ({:.1f} kbps)…").format(kbps), + receiver_thread, + on_success, + ) + + # -- finish --------------------------------------------------------------- + + def _review_and_sign(self): + try: + transfer = assemble(self.frames, self.total) + tx_strings = decode_transfer(transfer, self.compressed) + except (MissingFramesError, QrTransferError) as e: + self.show_error(str(e)) + return + if not tx_strings: + self.show_error(_("The transferred will contains no transactions.")) + return + self._finish_import(tx_strings) + + def _finish_import(self, tx_strings): + """Build local WillItems and open the review+sign wizard.""" + items = {} + for s in tx_strings: + try: + wi = WillItem({"tx": s}, wallet=self.bal_window.wallet) + except Exception as e: + self.show_error( + _("Could not parse a transferred transaction: {}").format(e) + ) + return + items[wi._id] = wi + Will.normalize_will(items, self.bal_window.wallet) + self._local_validity_pass(items) + for wi in items.values(): + wi.set_status("IMPORTED", True) + valid = [wid for wid in items if items[wid].get_status("VALID")] + if not valid: + self.show_error( + _( + "The imported will contains no valid transaction in this " + "wallet." + ) + ) + self.close() + return + self.close() + skipped = len(items) - len(valid) + if skipped: + self.show_warning( + _( + "{} imported transaction(s) are not valid in this wallet " + "and were skipped." + ).format(skipped) + ) + wizard = WillTxReviewSignDialog( + self.bal_window, will=items, bal_plugin=self.bal_plugin + ) + if wizard.aborted: + return + show_on_top(wizard) + + def _local_validity_pass(self, items): + """Local, wallet-only validity check (no server, no expiry raise). + + Mirrors the check that :meth:`BalWindow.merge_will` runs after a merge + so the import and the file-merge paths behave identically. + """ + date_to_check = getattr(self.bal_window, "date_to_check", None) + if date_to_check is None: + date_to_check = resolve_date_to_check( + self.bal_window.bal_plugin.is_basic_mode(), + self.bal_window.will_settings, + ) + history_label = self.bal_window.bal_plugin.HISTORY_LABEL.get() + try: + Will.add_willtree(items) + all_utxos = Util.get_available_utxos( + self.bal_window.wallet, + history_label, + Will.get_min_locktime(items, default_value=date_to_check), + ) + Will.check_invalidated( + items, Will.utxos_strs(all_utxos), self.bal_window.wallet + ) + Will.search_rai( + Will.get_all_inputs(items, only_valid=True), + all_utxos, + items, + self.bal_window.wallet, + ) + Will.check_signatures(items, self.bal_window.wallet) + except Exception as e: + log_error(e, self) + + +class WillTxReviewSignDialog(BalDialog): + """Per-transaction review + sign wizard for an imported will. + + Walks the (valid) imported transactions one at a time showing outputs, + total outputs and fees, with Sign / Skip / Cancel per page. All signing + runs on the local copy of the imported will; the live will and the wallet + history are never touched. The final page offers to export the signed + transactions as a file and/or as QR codes. + """ + + def __init__(self, bal_window, will=None, bal_plugin=None): + BalDialog.__init__( + self, bal_window.window, bal_plugin, _("Review and sign imported will") + ) + self.bal_window = bal_window + self.items = will if will is not None else bal_window.willitems + self.txids = sorted(Will.only_valid(self.items)) + self.aborted = False + self.i = 0 + if not self.txids: + self.aborted = True + self.close() + return + self.password = bal_window.get_wallet_password( + message=_( + "Enter your wallet password to sign the imported transactions." + ) + ) + if self.password is False: + self.aborted = True + self.close() + return + + vbox = QVBoxLayout(self) + self.stack = QStackedWidget(self) + self.summary_page = self._build_summary_page() + # Index 0 = summary page; review pages start at index 1. + self.stack.addWidget(self.summary_page) + self.review_pages = [] + for _txid in self.txids: + page = self._build_review_page() + self.stack.addWidget(page) + self.review_pages.append(page) + vbox.addWidget(self.stack) + self.stack.setCurrentIndex(1) + self._render() + + # -- page builders --------------------------------------------------------- + + def _build_review_page(self): + page = QWidget() + vbox = QVBoxLayout(page) + header = QLabel() + vbox.addWidget(header) + txid_label = QLabel() + txid_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + vbox.addWidget(txid_label) + outputs_label = QLabel() + outputs_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + vbox.addWidget(outputs_label) + totals_label = QLabel() + vbox.addWidget(totals_label) + + row = QHBoxLayout() + sign_btn = QPushButton(_("Sign")) + sign_btn.clicked.connect(self._sign_current) + row.addWidget(sign_btn) + skip_btn = QPushButton(_("Skip")) + skip_btn.clicked.connect(self._advance) + row.addWidget(skip_btn) + cancel_btn = QPushButton(_("Cancel")) + cancel_btn.clicked.connect(self.close) + row.addWidget(cancel_btn) + row.addStretch(1) + vbox.addLayout(row) + return page + + def _build_summary_page(self): + page = QWidget() + vbox = QVBoxLayout(page) + self.summary_label = QLabel() + self.summary_label.setWordWrap(True) + vbox.addWidget(self.summary_label) + save_btn = QPushButton(_("Save signed file…")) + save_btn.clicked.connect(self._save_signed) + vbox.addWidget(save_btn) + self.qr_btn = QPushButton(_("Show signed QR…")) + self.qr_btn.clicked.connect(self._show_signed_qr) + vbox.addWidget(self.qr_btn) + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + return page + + # -- rendering ------------------------------------------------------------- + + def _page_index(self): + return 0 if self.i >= len(self.txids) else self.i + 1 + + def _render(self): + if self.i >= len(self.txids): + self._enter_summary() + return + txid = self.txids[self.i] + wi = self.items[txid] + tx = wi.tx + page = self.review_pages[self.i] + + headers = page.findChildren(QLabel) + headers[0].setText( + _("Transaction {} of {}").format(self.i + 1, len(self.txids)) + ) + headers[1].setText(_("TXID: {}").format(txid)) + lines = [] + for o in tx.outputs(): + value = o.value if o.value is not None else _("unknown") + if isinstance(value, int): + value_s = self.bal_window.window.format_amount_and_units(value) + else: + value_s = value + lines.append("{} {}".format(o.get_ui_address_str(), value_s)) + headers[2].setText("\n".join(lines) if lines else _("(no outputs)")) + total_out = sum( + (o.value or 0) for o in tx.outputs() if isinstance(o.value, int) + ) + fee = None + try: + iv = tx.input_value() + if isinstance(iv, int): + fee = iv - total_out + except Exception: + fee = None + if fee is not None: + fee_s = self.bal_window.window.format_amount_and_units(fee) + else: + fee_s = _("unknown (partial transaction)") + headers[3].setText( + _("Total outputs: {}\nFee: {}").format( + self.bal_window.window.format_amount_and_units(total_out), fee_s + ) + ) + self.stack.setCurrentIndex(self._page_index()) + + def _enter_summary(self): + signed = sum( + 1 for txid in self.txids if self.items[txid].get_status("COMPLETE") + ) + self.summary_label.setText( + _( + "Signed {} of {} transactions.\n\nSave a signed file to carry " + "to the broadcast device, or show the signed transactions as " + "QR codes." + ).format(signed, len(self.txids)) + ) + self.qr_btn.setEnabled(signed > 0) + self.stack.setCurrentIndex(0) + + # -- actions --------------------------------------------------------------- + + def _sign_current(self): + txid = self.txids[self.i] + try: + tx, newly = self.bal_window._prepare_and_sign_tx( + self.items, txid, self.password + ) + except Exception as e: + log_error(e, self) + self.show_error(_("Could not sign the transaction: {}").format(e)) + return + if newly and tx.is_complete(): + self.items[txid].set_status("COMPLETE", True) + self._advance() + + def _advance(self): + self.i += 1 + self._render() + + def _save_signed(self): + data = {wid: wi.to_dict() for wid, wi in self.items.items()} + + def _do_save(path): + try: + write_json_file(path, data) + except Exception as e: + self.show_error(str(e)) + return + self.show_message(_("Signed will saved.")) + + export_meta_gui(self.bal_window.window, "will_signed.json", _do_save) + + def _show_signed_qr(self): + signed = { + wid: wi + for wid, wi in self.items.items() + if wid in self.txids and wi.get_status("COMPLETE") + } + if not signed: + self.show_message(_("No signed transaction to show.")) + return + d = WillQrExportDialog(self.bal_window, will=signed, bal_plugin=self.bal_plugin) + show_on_top(d) + diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 80069f7..11939e8 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -667,7 +667,9 @@ class PreviewList(MyTreeView, MessageBoxMixin): export_menu.addAction(_("All"), self.export_will) export_menu.addAction(_("Valid"), self.export_will_valid) export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete) + export_menu.addAction(_("QR Codes"), self.export_will_via_qr) menu.addAction(_("Import"), self.import_will_into_details) + menu.addAction(_("Import via QR"), self.import_will_via_qr) menu.addAction(_("Merge"), self.merge_will) menu.addAction(_("Broadcast"), self.broadcast) menu.addAction(_("Check"), self.check) @@ -769,6 +771,12 @@ class PreviewList(MyTreeView, MessageBoxMixin): def import_will_into_details(self): self.bal_window.import_will_into_details() + def export_will_via_qr(self): + self.bal_window.export_will_via_qr() + + def import_will_via_qr(self): + self.bal_window.import_will_via_qr() + def merge_will(self): self.bal_window.merge_will_ui() diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index ac1dc92..120cb56 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -19,6 +19,8 @@ from electrum.plugin import hook from electrum.util import EventListener, event_listener from PyQt6.QtWidgets import QLayout +from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size + from .common import ( _, _logger, @@ -531,6 +533,21 @@ class Plugin(BalPlugin, EventListener): # users (BASIC and ADVANCED). heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD) + # QR Code Size selector (will transfer via QR). A 4-standard-size combo + # bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame). + # Ordered low -> high so the user picks the resolution matching their + # camera. Visible to all users (BASIC and ADVANCED). + qr_size_combo = QComboBox() + qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS]) + qr_size_combo.setCurrentIndex( + preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get())) + ) + + def on_qr_size_change(index): + self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1]) + + qr_size_combo.currentIndexChanged.connect(on_qr_size_change) + # USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo # (not a free-text field) bound to the USER_TYPE config: # index 0 -> "BASIC" -> stored value "basic" (DEFAULT) @@ -647,6 +664,10 @@ class Plugin(BalPlugin, EventListener): widget.setCurrentIndex( 1 if str(cfg.default).lower() == "advanced" else 0 ) + elif kind == "qr_size": + widget.setCurrentIndex( + preset_index_for_chunk_size(int(cfg.default)) + ) btn.clicked.connect(reset) return btn @@ -905,6 +926,25 @@ class Plugin(BalPlugin, EventListener): ) grid.addWidget(reset_btn_auto_rebuild, 15, 3) + # "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR + # size used when exporting a will via QR codes; changeable per export + # inside the export dialog itself. + lbl_qr_size = QLabel(_("QR Code Size")) + help_qr_size = HelpButton( + "Payload size of a single QR code when exporting a will via QR.\n\n" + "Larger QR codes hold more data (fewer shots) but are easier to " + "scan with a high-resolution camera; smaller QR codes scan fine " + "even with low-resolution cameras but require more shots.\n" + "The same selector is available inside the export dialog." + ) + grid.addWidget(lbl_qr_size, 16, 0) + grid.addWidget(qr_size_combo, 16, 1) + grid.addWidget(help_qr_size, 16, 2) + reset_btn_qr_size = _make_reset_btn( + self.QR_CHUNK_SIZE, qr_size_combo, "qr_size" + ) + grid.addWidget(reset_btn_qr_size, 16, 3) + # ----------------------------------------------------------------- # # Group C / C4b: "Reset" button that restores the dialog settings to # # their factory defaults. It only resets the settings exposed by THIS # @@ -938,6 +978,7 @@ class Plugin(BalPlugin, EventListener): (self.HISTORY_LABEL, edit_history_label, "line"), (self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"), (self.AUTO_REBUILD, heir_auto_rebuild, "check"), + (self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"), ] for cfg, widget, kind in resets: # Persist the default value back into the Electrum config. @@ -958,6 +999,10 @@ class Plugin(BalPlugin, EventListener): widget.setCurrentIndex( 1 if str(cfg.default).lower() == "advanced" else 0 ) + elif kind == "qr_size": + widget.setCurrentIndex( + preset_index_for_chunk_size(int(cfg.default)) + ) # Re-sync the history-label field's enabled state after a reset: the # reset restores SAVE_HISTORY to its default, so the field must # follow the (default) checkbox state again. diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 789c2b3..ab7b10f 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -88,6 +88,8 @@ from .dialogs import ( BalWizardDialog, WillDetailDialog, WillExecutorDialog, + WillQrExportDialog, + WillQrImportDialog, ) from .lists import HeirListWidget, PreviewList from .widgets import LockTimeWidget, PercAmountEdit @@ -980,6 +982,13 @@ class BalWindow: return self.show_transaction_real(tx, parent=parent) def invalidate_will(self, will=None): + # The reference timestamp is normally set by init_class_variables(); + # fall back to "now" so a first-action invalidation always has it. + if not hasattr(self, "date_to_check") or self.date_to_check is None: + self.date_to_check = resolve_date_to_check( + self.bal_plugin.is_basic_mode(), self.will_settings + ) + def on_success(result): if result: self.show_message( @@ -1015,75 +1024,93 @@ class BalWindow: self.waiting_dialog.exe() def sign_transactions(self, password, will=None, txids=None): - try: - willitems = will if will is not None else self.willitems - txs = {} - signed = None - tosign = None + try: + willitems = will if will is not None else self.willitems + txs = {} + signed = None + tosign = None - def get_message(): - msg = "" - if signed: - msg = _(f"signed: {signed}\n") - return msg + _(f"signing: {tosign}") + def get_message(): + msg = "" + if signed: + msg = _(f"signed: {signed}\n") + return msg + _(f"signing: {tosign}") - if txids is not None: - targets = [ - t for t in txids - if t in willitems and willitems[t].get_status("VALID") - ] - else: - targets = Will.only_valid(willitems) - for txid in targets: - wi = willitems[txid] - # Do NOT deepcopy: the stored tx carries wallet-derived objects - # (utxo / script_descriptor) that hold a threading.RLock, and - # copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse - # from the serialized form instead, which is exactly how the will - # is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any). - tx = Will.get_tx_from_any(str(wi.tx)) - if wi.get_status("COMPLETE"): + if txids is not None: + targets = [ + t for t in txids + if t in willitems and willitems[t].get_status("VALID") + ] + else: + targets = Will.only_valid(willitems) + for txid in targets: + wi = willitems[txid] + if wi.get_status("COMPLETE"): + # Already signed and complete: keep as-is (the single-tx + # helper short-circuits without touching the wallet). + tx, _ = self._prepare_and_sign_tx(willitems, txid, password) + txs[txid] = tx + continue + tosign = txid + try: + self.waiting_dialog.update(get_message()) + except Exception: + pass + tx, _signed = self._prepare_and_sign_tx(willitems, txid, password) + signed = tosign txs[txid] = tx - continue - tosign = txid + except Exception: + return None + return txs + + def _prepare_and_sign_tx(self, willitems, txid, password): + """Prepare one will transaction and sign it. + + Shared by the batch signer (:meth:`sign_transactions`) and the + per-transaction review wizard of the QR import flow + (:class:`WillTxReviewSignDialog`). + + Returns ``(tx, newly_signed)``: ``newly_signed`` is False when the + transaction was already COMPLETE (nothing was signed). + """ + wi = willitems[txid] + # Do NOT deepcopy: the stored tx carries wallet-derived objects + # (utxo / script_descriptor) that hold a threading.RLock, and + # copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse + # from the serialized form instead, which is exactly how the will + # is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any). + tx = Will.get_tx_from_any(str(wi.tx)) + if wi.get_status("COMPLETE"): + return tx, False + for txin in tx.inputs(): + prevout = txin.prevout.to_json() + if prevout[0] in willitems: + change = willitems[prevout[0]].tx.outputs()[prevout[1]] + txin._trusted_value_sats = change.value try: - self.waiting_dialog.update(get_message()) + txin.script_descriptor = change.script_descriptor except Exception: pass - for txin in tx.inputs(): - prevout = txin.prevout.to_json() - if prevout[0] in willitems: - change = willitems[prevout[0]].tx.outputs()[prevout[1]] - txin._trusted_value_sats = change.value - try: - txin.script_descriptor = change.script_descriptor - except Exception: - pass - txin.is_mine = True - txin._TxInput__address = change.address - txin._TxInput__scriptpubkey = change.scriptpubkey - txin._TxInput__value_sats = change.value + txin.is_mine = True + txin._TxInput__address = change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + txin._trusted_value_sats = change.value - self.wallet.sign_transaction(tx, password, ignore_warnings=True) - signed = tosign - # is_complete = False - if tx.is_complete(): - # is_complete = True - wi.set_status("COMPLETE", True) - # Refresh the per-item signature counts from the freshly signed - # partial tx: at this point the signatures are still present - # (before any finalization), so the will list can show the real - # "added/required" count (e.g. "1/2" for a multisig). - try: - have, required = tx.signature_count() - wi.sigs_have = int(have) - wi.sigs_required = int(required) - except Exception as e: - _logger.debug(f"signature_count after signing failed: {e}") - txs[txid] = tx - except Exception: - return None - return txs + self.wallet.sign_transaction(tx, password, ignore_warnings=True) + if tx.is_complete(): + wi.set_status("COMPLETE", True) + # Refresh the per-item signature counts from the freshly signed + # partial tx: at this point the signatures are still present + # (before any finalization), so the will list can show the real + # "added/required" count (e.g. "1/2" for a multisig). + try: + have, required = tx.signature_count() + wi.sigs_have = int(have) + wi.sigs_required = int(required) + except Exception as e: + _logger.debug(f"signature_count after signing failed: {e}") + return tx, True def get_wallet_password(self, message=None, parent=None): parent = self.window if not parent else parent @@ -1620,6 +1647,52 @@ class BalWindow: self.show_error(str(e)) raise e + def export_will_via_qr(self, will=None): + """Export the will (default: the live one) as QR codes on screen. + + The selected transactions are serialized with the wire format of + :mod:`bal.core.qrtransfer` and shown, one frame at a time, in a + :class:`WillQrExportDialog`. When Electrum's ``audio_modem`` plugin + is available (:meth:`get_audio_modem_plugin`) the dialog also offers + an "Audio" send button. + """ + try: + willitems = will if will is not None else self.willitems + d = WillQrExportDialog(self, will=willitems, bal_plugin=self.bal_plugin) + show_on_top(d) + except Exception as e: + self.show_error(str(e)) + raise e + + def get_audio_modem_plugin(self): + """Return Electrum's ``audio_modem`` plugin instance, or None. + + The plugin is only usable when Electrum exposes it (the ``Plugins`` + manager knows the name) and its optional runtime dependency + ``amodem`` is installed (:meth:`is_available`). Every other case + returns None so callers can simply hide the audio buttons. + """ + try: + p = self.window.gui_object.plugins.get("audio_modem") + except Exception: + return None + if not p or not getattr(p, "is_available", lambda: False)(): + return None + return p + + def _audio_send_payload(self, payload): + """Send a transfer payload through the audio_modem plugin. + + Wraps the plugin's own ``_send`` with a proper parent widget. The + audio channel zlib-compresses internally, so the payload is passed + uncompressed (no BAL ``Z`` flag needed on that transport). + """ + plugin = self.get_audio_modem_plugin() + if plugin is None: + self.show_error(_("Audio MODEM plugin is not available.")) + return + plugin._send(parent=self.window, blob=payload) + def merge_will(self, imported): """Merge imported will items into the live will. @@ -1762,6 +1835,18 @@ class BalWindow: import_meta_gui(self.window, _("will"), on_file, on_success) + def import_will_via_qr(self): + """Import a will through QR codes (or audio) and review/sign it. + + Opens a :class:`WillQrImportDialog`. The captured transactions are + parsed into fresh :class:`WillItem` objects (never touching the + live will), run through the same local validity pass the merge flow + uses, and are then presented in the per-transaction review wizard + (:class:`WillTxReviewSignDialog`). + """ + d = WillQrImportDialog(self, bal_plugin=self.bal_plugin) + show_on_top(d) + def _load_will_file(self, path): data = read_json_file(path) willitems = {} diff --git a/tests/test_core_qr_transfer.py b/tests/test_core_qr_transfer.py new file mode 100644 index 0000000..51d924b --- /dev/null +++ b/tests/test_core_qr_transfer.py @@ -0,0 +1,327 @@ +""" +Tests for ``bal.core.qrtransfer``. + +Covers the BALQR frame encoding used for will transfer via QR codes / +audio modem: encoding, framing, reassembly, malformed input and the preset +list (optionally cross-checked against the ``qrcode`` library's EC-M +capacity when it is installed). + +Run: + source electrum/env/bin/activate + python3 tests/test_core_qr_transfer.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.qrtransfer import ( + CHUNK_PRESETS, + MIN_CHUNK_SIZE, + InconsistentTotalError, + MissingFramesError, + QrTransferError, + assemble, + decode_transfer, + encode_transfer, + parse_frame, + preset_index_for_chunk_size, + split_frames, +) + + +def _frames(tx_strings, chunk_size, compress=False): + """Split a payload and return (payload, total, {index: payload}).""" + payload = encode_transfer(tx_strings, compress=compress) + parsed = {} + total = None + for frame in split_frames(payload, chunk_size, compressed=compress): + t, index, _compressed, p = parse_frame(frame) + if total is not None: + assert total == t + total = t + parsed[index] = p + assert total is not None + return payload, total, parsed + + +# --------------------------------------------------------------------------- # +# Round trips +# --------------------------------------------------------------------------- # + + +def test_encode_decode_plain(): + tx_strings = ["00" * 32, "aa" * 40, "ff" * 50] + payload = encode_transfer(tx_strings, compress=False) + assert decode_transfer(payload, compressed=False) == tx_strings + + +def test_encode_decode_compressed(): + tx_strings = ["00" * 32, "aa" * 40, "ff" * 50] + payload = encode_transfer(tx_strings, compress=True) + assert decode_transfer(payload, compressed=True) == tx_strings + + +def test_empty_list_roundtrip(): + assert decode_transfer(encode_transfer([]), compressed=False) == [] + + +# --------------------------------------------------------------------------- # +# Framing +# --------------------------------------------------------------------------- # + + +def test_single_frame(): + tx_strings = ["11" * 10] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + assert len(frames) == 1 + total, index, compressed, p = parse_frame(frames[0]) + assert (total, index, compressed) == (1, 1, False) + assert p == payload + + +def test_multiple_frames_reassemble(): + tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames + _payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1]) + assert total > 1 + decoded = decode_transfer(assemble(parsed, total), compressed=False) + assert decoded == tx_strings + + +def test_size_greater_than_payload(): + tx_strings = ["12" * 5] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 1800) + assert len(frames) == 1 + _t, _i, _c, p = parse_frame(frames[0]) + assert p == payload + + +def test_exact_single_frame_boundary(): + # A 138-byte payload exactly fills the 150-byte preset budget (the 12-char + # empty-flags header plus payload), so the encoded frame is exactly 150. + tx_strings = ["a" * 138] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + assert len(frames) == 1 + assert len(frames[0]) == 150 + _t, _i, _c, p = parse_frame(frames[0]) + assert p == payload + + +def test_frames_fit_chunk_size(): + tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)] + payload = encode_transfer(tx_strings) + for _label, size in CHUNK_PRESETS: + for frame in split_frames(payload, size): + assert len(frame) <= size, (size, len(frame)) + + +def test_single_tx_larger_than_chunk(): + # A huge serialized tx must be split over several frames and reassemble + # exactly (positional slicing is safe for hex/base64 text). + tx_strings = ["7b" * 1000] # 2000 chars + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + assert len(frames) > 1 + parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames} + total = parse_frame(frames[0])[0] + assert assemble(parsed, total) == payload + + +def test_compressed_frames_carry_flag(): + tx_strings = ["ab" * 40] + frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True) + for frame in frames: + _t, _i, compressed, _p = parse_frame(frame) + assert compressed is True + # Plain frames do not. + frames_plain = split_frames(encode_transfer(tx_strings), 150) + _t, _i, compressed, _p = parse_frame(frames_plain[0]) + assert compressed is False + + +def test_compressed_roundtrip_through_frames(): + tx_strings = ["ab" * 50, "cd" * 50, "12" * 60] + payload = encode_transfer(tx_strings, compress=True) + parsed = {} + total = None + for frame in split_frames(payload, 400, compressed=True): + t, index, _c, p = parse_frame(frame) + total = t + parsed[index] = p + assert total is not None + decoded = decode_transfer(assemble(parsed, total), compressed=True) + assert decoded == tx_strings + + +# --------------------------------------------------------------------------- # +# Malformed input +# --------------------------------------------------------------------------- # + + +def test_parse_bad_magic_and_version(): + for frame in ( + "BALQR|1|1||a", # missing version + "BALQR2|1|1||a", # unknown version + "XXXXX1|1|1||a", # unknown magic + ): + try: + parse_frame(frame) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for: {}".format(frame)) + + +def test_parse_bad_arity(): + for frame in ("BALQR1", "BALQR1|1|1|"): + try: + parse_frame(frame) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for: {}".format(frame)) + + +def test_parse_pipe_in_payload_is_folded(): + # maxsplit keeps the tail (including any inner '|') in the payload part. + frame = "BALQR1|1|1||a|b|c" + total, index, compressed, payload = parse_frame(frame) + assert (total, index, compressed) == (1, 1, False) + assert payload == "a|b|c" + + +def test_parse_bad_numbers(): + for frame in ( + "BALQR1|x|1||a", + "BALQR1|1|y||a", + "BALQR1|0|1||a", + "BALQR1|1|0||a", + "BALQR1|1|2||a", # index beyond total + "BALQR1|-1|1||a", + ): + try: + parse_frame(frame) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for: {}".format(frame)) + + +def test_parse_bad_flags(): + try: + parse_frame("BALQR1|1|1|Q|payload") + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for unknown flags") + + +def test_assemble_missing_frames(): + try: + assemble({1: "a", 3: "c"}, total=3) + except MissingFramesError as e: + assert e.missing == [2] + else: + raise AssertionError("expected MissingFramesError") + + +def test_assemble_index_beyond_total(): + try: + assemble({1: "a", 2: "b"}, total=1) + except InconsistentTotalError: + pass + else: + raise AssertionError("expected InconsistentTotalError") + + +def test_assemble_order_and_total_validation(): + assert assemble({1: "a", 2: "b"}, total=2) == "ab" + try: + assemble({}, total=0) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError") + + +# --------------------------------------------------------------------------- # +# Constants / presets +# --------------------------------------------------------------------------- # + + +def test_preset_count_and_order(): + assert len(CHUNK_PRESETS) == 4 + budgets = [budget for _label, budget in CHUNK_PRESETS] + assert budgets == sorted(budgets) + + +def test_preset_index_for_chunk_size(): + for index, (_label, budget) in enumerate(CHUNK_PRESETS): + assert preset_index_for_chunk_size(budget) == index + assert preset_index_for_chunk_size(150) == 0 + assert preset_index_for_chunk_size(1800) == 3 + + +def test_min_chunk_size_guard(): + try: + split_frames("x" * 10, MIN_CHUNK_SIZE - 1) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for tiny chunk size") + + +def test_split_frame_headers_consistent(): + tx_strings = ["ab" * 80] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + totals = {parse_frame(frame)[0] for frame in frames} + assert len(totals) == 1 + assert totals.pop() == len(frames) + + +# --------------------------------------------------------------------------- # +# Optional: cross-check presets against the qrcode library (EC level M) +# --------------------------------------------------------------------------- # + + +def test_presets_fit_qrcode_ec_m(): + """Every preset budget must render inside a QR at EC level M.""" + try: + import qrcode + + from qrcode.constants import ERROR_CORRECT_M + except ImportError: + print("qrcode not installed - skipping capacity check") + return + for _label, size in CHUNK_PRESETS: + # Worst-case frame: header with the largest plausible total/index plus + # a full payload of the preset budget. + frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14) + qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2) + qr.add_data(frame) + qr.get_matrix() # raises DataOverflowError if it does not fit + + +# --------------------------------------------------------------------------- # +if __name__ == "__main__": + import traceback + + failures = 0 + for _name, fn in sorted(globals().items()): + if _name.startswith("test_") and callable(fn): + try: + fn() + print("ok: {}".format(_name)) + except Exception: + failures += 1 + print("FAIL: {}".format(_name)) + traceback.print_exc() + if failures: + print("{} test(s) failed".format(failures)) + sys.exit(1) + print("all tests passed") \ No newline at end of file diff --git a/tests/test_gui_qr_transfer.py b/tests/test_gui_qr_transfer.py new file mode 100644 index 0000000..d73b762 --- /dev/null +++ b/tests/test_gui_qr_transfer.py @@ -0,0 +1,343 @@ +""" +Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``). + +Covers WillQrExportDialog (build, frame navigation, chunk-size change) and +WillQrImportDialog (frame capture, slot grid, complete-review enabling, total +mismatch reset, frame assembly -> decode). The wizard and the camera/audio +paths need a live wallet/hardware and are exercised only through the shared +frame-assembly path here. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py +""" + +import base64 +import sys +from unittest.mock import patch + +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from electrum.transaction import Transaction +from PyQt6.QtWidgets import QApplication, QMainWindow + +import bal.gui.qt.dialogs as dialogs +from bal.core.qrtransfer import encode_transfer, split_frames +from bal.core.will import WillItem + +_app = QApplication.instance() or QApplication(sys.argv) + +# A valid 1x1 transparent PNG, good enough for BalDialog's window icon. +_PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output), +# reused for the WillItem status regression test. +_VALID_TX_HEX = ( + "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" + "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" + "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" + "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" + "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" + "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" + "42146f11ef8414ae929feaafc388ac00000000" +) + + +class FakePlugin: + def read_file(self, path): + return _PNG_BYTES + + +class FakeWindow(QMainWindow): + config = {} + + def format_amount(self, amount): + return "{:.8f}".format(amount) + + def format_amount_and_units(self, amount): + return "{:.8f} sat".format(amount) + + +class FakeBalWindow: + """Duck-typed stand-in for BalWindow (dialog layer only).""" + + def __init__(self): + self.window = FakeWindow() + self.bal_plugin = FakePlugin() + self.willitems = {} + + def get_audio_modem_plugin(self): + return None + + +class StubTx: + def __init__(self, payload): + self.payload = payload + + def txid(self): + return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF) + + def __str__(self): + return self.payload + + +class StubWillItem: + def __init__(self, payload, statuses=None): + self.tx = StubTx(payload) + self.statuses = statuses or {} + + def get_status(self, name): + return self.statuses.get(name, False) + + +def _make_willitems(n=3, payload_len=120): + return { + "item{}".format(i): StubWillItem("T{}".format(i) * payload_len) + for i in range(n) + } + + +# ------------------------------------------------------------------ # +# WillQrExportDialog +# ------------------------------------------------------------------ # + +def test_export_dialog_builds(): + bw = FakeBalWindow() + bw.willitems = _make_willitems() + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + assert d.tx_strings + assert d.frames + assert len(d.frames) >= 1 + # Frame 1 is shown. + assert d.qr_view.text == d.frames[0] + assert "1" in d.progress_label.text() + d.close() + + +def test_export_dialog_empty_close(): + # An empty will shows a modal message; stub it out for the test. + orig = dialogs.MessageBoxMixin.show_message + dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None + try: + bw = FakeBalWindow() + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + assert not d.isVisible() + d.close() + finally: + dialogs.MessageBoxMixin.show_message = orig + + +def test_imported_item_status_not_none(): + # Regression: a WillItem built from a bare {"tx": hex} had a None status, + # so set_status (e.g. IMPORTED / INVALIDATED from the import validity + # pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and + # 'str'". + with patch.object(Transaction, "add_info_from_wallet"): + wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None) + assert wi.status == "" + assert wi.set_status("IMPORTED", True) is True + assert wi.set_status("INVALIDATED", True) is True + assert "Imported" in wi.status and "Invalidated" in wi.status + + +def test_export_auto_scroll(): + bw = FakeBalWindow() + bw.willitems = _make_willitems(n=6, payload_len=400) + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + assert d.fps_spin is not None + assert not d.auto_timer.isActive() + + d.fps_spin.setValue(2) + d._toggle_auto() + assert d.auto_timer.isActive() + assert d.auto_btn.text() == dialogs._("Stop") + d._auto_step() + assert d.index == 1 + d._toggle_auto() + assert not d.auto_timer.isActive() + assert d.auto_btn.text() == dialogs._("Auto") + + # Advancing past the last frame stops the slideshow automatically. + d._toggle_auto() + d.index = len(d.frames) - 1 + d._auto_step() + assert not d.auto_timer.isActive() + d.close() + + +def test_export_auto_scroll_loop(): + bw = FakeBalWindow() + bw.willitems = _make_willitems(n=6, payload_len=400) + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + assert d.loop_check is not None + + # Loop on: reaching the last code wraps back to the first and keeps going. + d.loop_check.setChecked(True) + d._toggle_auto() + assert d.auto_timer.isActive() + d.index = len(d.frames) - 1 + d._auto_step() + assert d.index == 0 + assert d.auto_timer.isActive() + d._toggle_auto() + + # Loop off: reaching the last code stops the slideshow. + d.loop_check.setChecked(False) + d._toggle_auto() + d.index = len(d.frames) - 1 + d._auto_step() + assert not d.auto_timer.isActive() + d.close() + + +def test_export_filter_valid_and_valid_nc(): + bw = FakeBalWindow() + a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) + b = StubWillItem("B" * 120, statuses={"VALID": True}) + c = StubWillItem("C" * 120) + bw.willitems = {"a": a, "b": b, "c": c} + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + assert len(d.tx_strings) == 3 + assert d.filter_combo.count() == 3 + + # "Valid" filter -> only the valid items (a, b). + d._on_filter_change(1) + assert sorted(d.tx_strings) == ["A" * 120, "B" * 120] + + # "Valid NC" filter -> only the valid, not-complete item (b). + d._on_filter_change(2) + assert sorted(d.tx_strings) == ["B" * 120] + assert d.qr_view.text == d.frames[0] + d.close() + + +def test_export_filter_empty_reverts(): + bw = FakeBalWindow() + # Only a COMPLETE valid item: "Valid NC" selects nothing -> revert. + bw.willitems = { + "a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) + } + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + messages = [] + d.show_message = lambda msg: messages.append(msg) + d._on_filter_change(2) # "Valid NC" -> empty subset + assert messages + assert d._filter_index == 0 # reverted to "All" + assert d.filter_combo.currentIndex() == 0 + assert len(d.tx_strings) == 1 + d.close() + + +def test_export_navigation_and_chunk_change(): + bw = FakeBalWindow() + bw.willitems = _make_willitems(n=6, payload_len=400) + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + first_count = len(d.frames) + assert first_count > 1 # long transfer, small default chunk + + assert not d.prev_btn.isEnabled() + d._next() + assert d.index == 1 + assert d.qr_view.text == d.frames[1] + assert d.prev_btn.isEnabled() + d._prev() + assert d.index == 0 + assert d.qr_view.text == d.frames[0] + + # Switch to the largest preset: fewer, bigger frames. + d._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1) + assert len(d.frames) < first_count + assert d.index == 0 + d.close() + + +# ------------------------------------------------------------------ # +# WillQrImportDialog +# ------------------------------------------------------------------ # + +def test_import_frame_flow(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + assert not d.review_btn.isEnabled() + assert not d.slot_area.isVisible() + + transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120]) + frames = split_frames(transfer, 150) + assert len(frames) > 1 + + for frame in frames: + d._add_frame(frame) + assert d.total == len(frames) + assert d.review_btn.isEnabled() + # isVisible() needs a shown parent; assert the widget is not hidden instead. + assert not d.slot_area.isHidden() + assert len(d.slot_widgets) == d.total + assert "All" in d.status_label.text() + # Duplicate capture is harmless. + d._add_frame(frames[0]) + assert len(d.frames) == d.total + d.close() + + +def test_import_assembles_and_decodes(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + captured = {} + + def fake_finish(tx_strings): + captured["tx_strings"] = tx_strings + + d._finish_import = fake_finish + transfer = encode_transfer(["P" * 130, "Q" * 130]) + for frame in split_frames(transfer, 150): + d._add_frame(frame) + d._review_and_sign() + assert captured["tx_strings"] == ["P" * 130, "Q" * 130] + d.close() + + +def test_import_total_mismatch_resets(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + warnings = [] + d.show_warning = lambda msg: warnings.append(msg) + + frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) + frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150) + for frame in frames_a: + d._add_frame(frame) + assert d.total == len(frames_a) + + # A frame with a different total wipes the import; the first frame of + # the new transfer must be scanned afresh. + d._add_frame(frames_b[0]) + assert warnings + assert d.total == 0 + assert not d.frames + assert not d.review_btn.isEnabled() + d.close() + + +def test_import_manual_entry(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + frames = split_frames(encode_transfer(["M" * 120]), 150) + d.manual_edit.setText(frames[0]) + d._add_from_manual() + assert d.manual_edit.text() == "" + assert d.total == 1 + assert d.review_btn.isEnabled() + d.close() + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All QR transfer GUI tests passed")