2 Commits

Author SHA1 Message Date
ce3e36db70 chore: stop tracking tests/karen7 (generated wallet fixture); add to .gitignore 2026-08-28 16:28:59 -04:00
d288b553ef 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.
2026-08-28 16:28:37 -04:00
18 changed files with 3107 additions and 2764 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ tmp*
# Release artifacts
bal_v*.zip.*
tests/karen7

170
AUDIO_MODEM_DEBIAN.md Normal file
View File

@@ -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.01.2 kbps (a ~2 KB will takes
~1520 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
```

View File

@@ -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 P0P6, 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
`<sink>.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.

View File

@@ -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`)
- P0P4 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 <sink>.monitor` (restore after).

482
PLAN_QR_TRANSFER.md Normal file
View File

@@ -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 02).
- 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|<total>|<index>|<flags>|<payload>
```
- Magic+version literal `BALQR1` (reject anything else with a clear message;
keeps the door open for a future `BALQR2`).
- `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`.
- `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed).
- `<payload>`: the i-th slice of `transfer_string`, exactly
`chunk_size` bytes each (last slice may be shorter).
- Header overhead ≈ 1620 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.

379
QML_PLAN.md Normal file
View File

@@ -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 `<plugin>/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 `<plugins>/<name>/qml/<loader>.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) | ~600800 lines Python |
| 3 views (offline signer page first) | ~9001300 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).

View File

@@ -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
(1501800 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

View File

@@ -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

212
bal/core/qrtransfer.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()

View File

@@ -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.

View File

@@ -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 = {}

File diff suppressed because it is too large Load Diff

View File

@@ -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")

View File

@@ -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")