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.
This commit is contained in:
170
AUDIO_MODEM_DEBIAN.md
Normal file
170
AUDIO_MODEM_DEBIAN.md
Normal 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.0–1.2 kbps (a ~2 KB will takes
|
||||||
|
~15–20 s of audio); higher bitrates are faster but less robust.
|
||||||
|
2. Wallet A → BAL will list → **Export → QR Codes → Audio…**
|
||||||
|
(the audio transport sends the raw newline-joined tx list, no BAL framing).
|
||||||
|
3. Wallet B → will list → **Import via QR → Audio…** → wait for
|
||||||
|
"Waiting for audio (... kbps)…", a loading cursor while demodulating,
|
||||||
|
then the decoded slots appear → review/sign wizard opens.
|
||||||
|
4. One machine only: apply the §4 monitor trick in the shell where Electrum
|
||||||
|
runs (export plays to the sink; import records from the sink monitor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub |
|
||||||
|
| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer |
|
||||||
|
| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines |
|
||||||
|
| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` |
|
||||||
|
| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. No-sudo quick reference (all commands)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install amodem
|
||||||
|
mkdir -p /tmp/portaudio_stub
|
||||||
|
ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||||
|
export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||||
|
# numpy >= 2 (one of):
|
||||||
|
pip install "numpy<2" # or patch amodem/common.py tobytes
|
||||||
|
```
|
||||||
105
CHANGELOG.md
105
CHANGELOG.md
@@ -2908,3 +2908,108 @@ renumber the grid rows.
|
|||||||
- Full test suite: 438 passed (unchanged).
|
- Full test suite: 438 passed (unchanged).
|
||||||
|
|
||||||
**Outcome:** DONE.
|
**Outcome:** DONE.
|
||||||
|
|
||||||
|
## 56. QR / audio will transfer (chunked multi-QR export/import + review-and-sign wizard)
|
||||||
|
|
||||||
|
**Date:** 2026-08-27
|
||||||
|
|
||||||
|
**Goal (owner request):** export a will to another device via QR codes (with
|
||||||
|
an optional audio channel on top), and import it there with a per-transaction
|
||||||
|
review-and-sign flow. QR codes are chunked because a full will usually exceeds
|
||||||
|
a single code's capacity.
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
|
||||||
|
- `bal/core/qrtransfer.py` (new, GUI-free): the wire format and chunk
|
||||||
|
scheduler — `BALQR{version}|{total}|{index}|{flags}|{payload}` frames,
|
||||||
|
`encode_transfer` / `decode_transfer`, `split_frames` / `parse_frame` /
|
||||||
|
`assemble`, optional `Z` (zlib+base64) compression at export, four chunk
|
||||||
|
presets (150/400/900/1800 bytes/frame, EC level M), plus
|
||||||
|
`preset_index_for_chunk_size` and the `QrTransferError` exception family.
|
||||||
|
- `tests/test_core_qr_transfer.py` (new): round-trips (plain/compressed),
|
||||||
|
boundaries (exact-fit, size>payload, `|` in payload), min-size guard, bad
|
||||||
|
magic/version/numbers/flags, multi-frame reassembly, header consistency.
|
||||||
|
- `bal/core/plugin_base.py`: new `QR_CHUNK_SIZE` config (default 150).
|
||||||
|
- `bal/gui/qt/plugin.py`: settings-dialog row 16 "QR Code Size" combo
|
||||||
|
(4 presets) + reset button, visible in BASIC and ADVANCED.
|
||||||
|
- `bal/gui/qt/dialogs.py`:
|
||||||
|
- `BalQrImage`: QR widget with MEDIUM error correction (Electrum's
|
||||||
|
`QRCodeWidget` is EC-L), reusing `draw_qr`.
|
||||||
|
- `WillQrExportDialog`: walks the frames (Prev/Next, "i of N" progress),
|
||||||
|
export filters **All / Valid / Valid-NC**, live chunk-preset selector,
|
||||||
|
**Auto slideshow** (toggle button + "QR codes per second" spinbox, stops on
|
||||||
|
the last frame and on filter/chunk changes), optional audio-send button.
|
||||||
|
- `WillQrImportDialog`: camera scan (Electrum `scan_qrcode_from_camera`,
|
||||||
|
one code at a time), manual paste fallback, slot grid (1..N) with
|
||||||
|
green=stored, total-mismatch reset, optional audio-receive that mirrors
|
||||||
|
the audio_modem `_recv` sink with a callback instead of `setText`.
|
||||||
|
- `WillTxReviewSignDialog`: per-transaction review (outputs via
|
||||||
|
`get_ui_address_str`, total outputs, fee) with Sign / Skip / Cancel and a
|
||||||
|
single wallet password; final page offers "Save signed file…" and
|
||||||
|
"Show signed QR…". Runs on the imported local copy only.
|
||||||
|
- `bal/gui/qt/window.py`: `export_will_via_qr`, `import_will_via_qr`,
|
||||||
|
`get_audio_modem_plugin`, `_audio_send_payload`; `sign_transactions`
|
||||||
|
refactored into a byte-equivalent batch loop plus the reusable
|
||||||
|
`_prepare_and_sign_tx(…, txid, password)` single-transaction helper.
|
||||||
|
- `bal/gui/qt/lists.py`: will-list menu gains **Export → QR Codes** and
|
||||||
|
**Import via QR**.
|
||||||
|
- `tests/test_gui_qr_transfer.py` (new): export build/navigation/chunk
|
||||||
|
change, filters (Valid, Valid-NC, empty-revert), import frame flow,
|
||||||
|
assembly+decode, total-mismatch reset, manual entry.
|
||||||
|
- `PLAN_QR_TRANSFER.md`: the full spec (wire format, settings, export,
|
||||||
|
import, wizard checklist P0–P6, findings log).
|
||||||
|
- Docs: `README.md` QR-transfer section; `QML_PLAN.md` updated (Phase 2
|
||||||
|
`BalQrTransferModel`, Phase 3 dedicated QML export/import pages, R6
|
||||||
|
mitigation rewritten, deferred-chunks note removed).
|
||||||
|
|
||||||
|
**Audio channel caveats:**
|
||||||
|
- The audio send/receive buttons only appear when Electrum's `audio_modem`
|
||||||
|
plugin is enabled *and* `amodem` + PortAudio are installed (not present in
|
||||||
|
the current dev runtime — verified F22). On that channel the transport
|
||||||
|
zlib-compresses internally, so no BAL framing/`Z` flag is used.
|
||||||
|
- `WaitingDialog` requires a real `QWidget` parent and the plugin's `_recv`
|
||||||
|
hard-wires `parent.setText`, so receive uses a local mirror with a
|
||||||
|
callback sink.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `python3 tests/test_core_qr_transfer.py`: all pass.
|
||||||
|
- `QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py`: all pass.
|
||||||
|
- Pytest batch `tests/test_core_*.py tests/test_gui_*.py`: 374 passed (only
|
||||||
|
the two pre-existing `test_bt_to_date_*` datetime compare failures remain).
|
||||||
|
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||||
|
electrum.plugins.bal`: passed (clean import under real Electrum).
|
||||||
|
- `ruff`: no new violations on changed files.
|
||||||
|
|
||||||
|
**Audio-environment notes (dev box, discovered while testing):**
|
||||||
|
- `amodem` 1.16.0 is old and uses `np.ndarray.tostring()`, removed in numpy 2.x;
|
||||||
|
the runtime venv (numpy 2.4.6) needs the one-line patch
|
||||||
|
`tostring()` → `tobytes()` in
|
||||||
|
`electrum/env/lib/python3.11/site-packages/amodem/common.py` (done locally,
|
||||||
|
not in the repo). Any machine with numpy>=2 and this amodem version needs
|
||||||
|
the same patch (or numpy<2).
|
||||||
|
- Electrum's `audio_modem` plugin hardcodes `libportaudio.so` (unversioned).
|
||||||
|
Debian/Ubuntu only ship `libportaudio.so.2`, so the load fails silently
|
||||||
|
inside the plugin's `_send` `WaitingDialog` (no `on_error` → no sound, no
|
||||||
|
message). Fix on the dev box:
|
||||||
|
`sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||||
|
(created by the `libportaudio-dev` package; a `LD_LIBRARY_PATH` stub works
|
||||||
|
without root). The audio buttons stay hidden unless the plugin is enabled
|
||||||
|
and available.
|
||||||
|
- Verified on the dev box (no physical mic required) via a full
|
||||||
|
send→sink→monitor→recv round-trip: set the default PulseAudio source to
|
||||||
|
`<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.
|
||||||
|
|||||||
27
HANDOFF.md
27
HANDOFF.md
@@ -392,3 +392,30 @@ See Section 5 for details.
|
|||||||
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
||||||
**Release** with the ZIP + signatures attached (it becomes the owner's
|
**Release** with the ZIP + signatures attached (it becomes the owner's
|
||||||
"Latest" download). Always give the owner the Release URL.
|
"Latest" download). Always give the owner the Release URL.
|
||||||
|
|
||||||
|
### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`)
|
||||||
|
|
||||||
|
- P0–P4 of `PLAN_QR_TRANSFER.md` are implemented on
|
||||||
|
`feature/bal-qr-transfer` and un-committed: core scheduler
|
||||||
|
(`bal/core/qrtransfer.py`), settings row, export/import dialogs +
|
||||||
|
review/sign wizard + lists/window wiring, all with tests
|
||||||
|
(`tests/test_core_qr_transfer.py`, `tests/test_gui_qr_transfer.py`) and
|
||||||
|
docs (README, CHANGELOG entry 56, QML_PLAN, this file).
|
||||||
|
- Remaining: **P6** release hygiene — `python3 build_zip.py`, external-zip
|
||||||
|
test, full ruff check (no NEW violations; baseline is intentionally dirty),
|
||||||
|
and a manual on-device walkthrough of the QR path. The audio path is
|
||||||
|
optional and hidden until the `audio_modem` plugin + `amodem` are
|
||||||
|
installed.
|
||||||
|
- Commit policy unchanged: nothing is committed until the owner explicitly
|
||||||
|
confirms.
|
||||||
|
|
||||||
|
**Dev-box audio prerequisites (audio_modem channel):**
|
||||||
|
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
|
||||||
|
two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal):
|
||||||
|
- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||||
|
(the unversioned name the plugin loads; Debian ships only `.so.2`).
|
||||||
|
- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` →
|
||||||
|
`tobytes()` (already applied locally). Both are runtime-env fixes, not repo
|
||||||
|
changes; see CHANGELOG entry 56.
|
||||||
|
- To loop-test on one machine without speakers/mic: during receive,
|
||||||
|
`pactl set-default-source <sink>.monitor` (restore after).
|
||||||
|
|||||||
482
PLAN_QR_TRANSFER.md
Normal file
482
PLAN_QR_TRANSFER.md
Normal 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 0–2).
|
||||||
|
- Merging imported wills into the live wallet state (existing Merge flows
|
||||||
|
stay unchanged).
|
||||||
|
- Broadcast of the reviewed transactions (user exports them; broadcasting
|
||||||
|
remains an explicit action elsewhere).
|
||||||
|
- CLI/cmdline parity for QR transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Owner decisions (locked)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|----------|
|
||||||
|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||||
|
| D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. |
|
||||||
|
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||||
|
| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. |
|
||||||
|
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||||
|
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||||
|
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verified facts (research done on the local checkouts)
|
||||||
|
|
||||||
|
All verified by reading source; references are `file:line`.
|
||||||
|
|
||||||
|
| # | Fact | Where |
|
||||||
|
|---|------|-------|
|
||||||
|
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
|
||||||
|
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
|
||||||
|
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
|
||||||
|
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
|
||||||
|
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
|
||||||
|
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
|
||||||
|
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
|
||||||
|
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
|
||||||
|
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
|
||||||
|
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
|
||||||
|
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
|
||||||
|
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
|
||||||
|
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
|
||||||
|
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
|
||||||
|
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
|
||||||
|
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
|
||||||
|
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
|
||||||
|
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
|
||||||
|
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
|
||||||
|
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
|
||||||
|
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
|
||||||
|
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
|
||||||
|
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wire format specification
|
||||||
|
|
||||||
|
### 4.1 Transfer string
|
||||||
|
|
||||||
|
```
|
||||||
|
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
|
||||||
|
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
|
||||||
|
safe delimiters.
|
||||||
|
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
|
||||||
|
- If compression enabled (D4):
|
||||||
|
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
|
||||||
|
|
||||||
|
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||||
|
|
||||||
|
```
|
||||||
|
BALQR1|<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 ≈ 16–20 bytes → effective payload = `chunk_size − overhead`;
|
||||||
|
the chunker slices the transfer string so that **header+payload ≤ preset
|
||||||
|
size**.
|
||||||
|
|
||||||
|
### 4.3 Size presets (D5)
|
||||||
|
|
||||||
|
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|
||||||
|
|--------------|------------------------------|--------------------------|
|
||||||
|
| Small (low-res cameras) | 150 | ~v10 |
|
||||||
|
| Medium | 400 | ~v15 |
|
||||||
|
| Large | 900 | ~v22 |
|
||||||
|
| XL (high-res cameras) | 1800 | ~v40 |
|
||||||
|
|
||||||
|
EC level fixed **M** for scan reliability (D5). Presets live in
|
||||||
|
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
|
||||||
|
|
||||||
|
### 4.4 Audio-modem channel (D7, F20-F23)
|
||||||
|
|
||||||
|
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
|
||||||
|
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
|
||||||
|
compresses internally; double compression wastes airtime).
|
||||||
|
- The existing core functions `encode_transfer(tx_strings,
|
||||||
|
compress=False)` + `decode_transfer(text, compressed=False)` are reused
|
||||||
|
unchanged; only the transport differs.
|
||||||
|
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
|
||||||
|
no setting of its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. New core module — `bal/core/qrtransfer.py`
|
||||||
|
|
||||||
|
GUI-free (never imports Qt — house rule). Public API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MAGIC = "BALQR"
|
||||||
|
VERSION = 1
|
||||||
|
FLAG_COMPRESSED = "Z"
|
||||||
|
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
|
||||||
|
|
||||||
|
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
||||||
|
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||||
|
|
||||||
|
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||||
|
"""Slice into full frames 'BALQR1|N|i|flags|payload'. Raises
|
||||||
|
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||||
|
|
||||||
|
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||||
|
"""-> (total, index, compressed, payload); ValueError on bad magic/
|
||||||
|
version/arity/non-int fields."""
|
||||||
|
|
||||||
|
def assemble(frames: dict[int, str]) -> str
|
||||||
|
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||||
|
frame header), concatenate payloads in order, decode flags ->
|
||||||
|
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
|
||||||
|
|
||||||
|
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
|
||||||
|
"""Inverse of encode_transfer -> list of tx strings."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
|
||||||
|
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
|
||||||
|
(line-length 88, E501 ignored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Settings (Qt)
|
||||||
|
|
||||||
|
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
|
||||||
|
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
|
||||||
|
`_hide_if_basic`):
|
||||||
|
|
||||||
|
- Label: `"QR Code Size"`
|
||||||
|
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
|
||||||
|
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
|
||||||
|
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
|
||||||
|
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
|
||||||
|
was customized).
|
||||||
|
- `HelpButton` text: explains trade-off (small QR = more shots but easier
|
||||||
|
to scan with poor cameras; large QR = fewer shots, needs good camera)
|
||||||
|
and that the size can also be changed inside the export dialog.
|
||||||
|
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
|
||||||
|
pattern (plugin.py:684).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Qt export flow
|
||||||
|
|
||||||
|
### 7.1 Entry point
|
||||||
|
|
||||||
|
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
|
||||||
|
|
||||||
|
```python
|
||||||
|
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
|
||||||
|
```
|
||||||
|
|
||||||
|
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
|
||||||
|
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
|
||||||
|
`show_message(_("No valid will item to export"))`, else
|
||||||
|
`self.bal_window.export_will_via_qr(will=subset)`.
|
||||||
|
|
||||||
|
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
|
||||||
|
`export_will`)
|
||||||
|
|
||||||
|
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
|
||||||
|
(F11).
|
||||||
|
- Mark exported items `EXPORTED` (parity with `export_json_file`,
|
||||||
|
window.py:1607-1609) — only when `will` came from the live list.
|
||||||
|
- Open `WillQrExportDialog(self, tx_strings)`.
|
||||||
|
|
||||||
|
Shared helper used by both dialogs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_audio_modem_plugin(self): # on BalWindow
|
||||||
|
"""Return the loaded audio_modem plugin if enabled AND available
|
||||||
|
(amodem importable), else None. Never raises."""
|
||||||
|
p = self.window.plugins.get("audio_modem") # F22
|
||||||
|
return p if p is not None and p.is_available() else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)]
|
||||||
|
[ QR image ] ← BalQrImage (see below)
|
||||||
|
«i di N» [◀ Prev] [Next ▶]
|
||||||
|
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- On any control change: rebuild `split_frames(encode_transfer(...))`,
|
||||||
|
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||||
|
risoluzione").
|
||||||
|
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||||
|
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
|
||||||
|
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
|
||||||
|
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
|
||||||
|
- Prev/Next wrap or disable at ends (disable chosen: clearer).
|
||||||
|
- PNG export optional convenience via existing
|
||||||
|
`getSaveFileName` + `QWidget.grab()` (same trick as
|
||||||
|
`qrcodewidget.py:110`).
|
||||||
|
- **Send via Audio Modem…** (D7): shown only when
|
||||||
|
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
|
||||||
|
otherwise hidden. Handler: re-encode the payload **plain**
|
||||||
|
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
|
||||||
|
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
|
||||||
|
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
|
||||||
|
if the plugin is enabled but `is_available()` is False, show an info
|
||||||
|
message pointing to `pip install amodem` + portaudio (F22).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Qt import flow + review/sign wizard
|
||||||
|
|
||||||
|
### 8.1 Entry point
|
||||||
|
|
||||||
|
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
|
||||||
|
|
||||||
|
```python
|
||||||
|
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
|
||||||
|
```
|
||||||
|
|
||||||
|
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
|
||||||
|
|
||||||
|
### 8.2 `WillQrImportDialog(BalDialog)`
|
||||||
|
|
||||||
|
State: `self.frames: dict[int, str]`, `self.total: int | None`,
|
||||||
|
`self.target_index: int | None`.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
«Captured k of N» [Scan ▶] [Reset]
|
||||||
|
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
|
||||||
|
hint line («Select a slot, then scan» / «Scan the first QR»)
|
||||||
|
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- **Scan** → `scan_qrcode_from_camera(parent=self,
|
||||||
|
config=self.bal_window.window.config, callback=self._on_scan)`
|
||||||
|
(F13). One-shot per press; dialog stays open between shots (simplest,
|
||||||
|
matches Electrum UX; no continuous mode).
|
||||||
|
- `_on_scan(success, error, data)`:
|
||||||
|
- failure → `show_error(error)` (covers missing zbar/camera too);
|
||||||
|
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
|
||||||
|
- first valid frame adopts `total` and materializes the slot grid;
|
||||||
|
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
|
||||||
|
restarted the export with another size);
|
||||||
|
- valid → `frames[index] = payload`; auto-advance `target_index` to the
|
||||||
|
lowest missing index; refresh grid + counter.
|
||||||
|
- Clicking an empty slot sets `target_index` (owner requirement: manual
|
||||||
|
shot selection); a filled slot click asks to overwrite.
|
||||||
|
- **Receive via Audio Modem…** (D7): shown only when
|
||||||
|
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
|
||||||
|
build a tiny adapter object exposing `setText(str)` that stores the text
|
||||||
|
and invokes the shared post-receive continuation, then call
|
||||||
|
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
|
||||||
|
received string is treated as the **whole payload**: skip frames/slots
|
||||||
|
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
|
||||||
|
item-building step (WillItem construction + validity pass + wizard).
|
||||||
|
Errors from the modem surface through the plugin's own dialog; empty
|
||||||
|
result (user cancelled) is silently ignored.
|
||||||
|
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
|
||||||
|
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
|
||||||
|
surfaces as `show_error` and keeps the dialog open.
|
||||||
|
- Build items exactly like `merge_single_transaction` (F4):
|
||||||
|
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
|
||||||
|
are collected and reported at the end (bad string ≠ fatal for the rest).
|
||||||
|
- Local validity pass (F5 recipe) on the resulting dict; items failing
|
||||||
|
`VALID` are dropped and listed in a warning. Set
|
||||||
|
`wi.set_status("IMPORTED", True)` on survivors (mirrors
|
||||||
|
`import_will_into_details`, window.py:1753-1754).
|
||||||
|
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
|
||||||
|
result → stop with a message.
|
||||||
|
|
||||||
|
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
|
||||||
|
|
||||||
|
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
|
||||||
|
imported subset lives **outside** the live wallet state (external mode,
|
||||||
|
F3).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **Password once**: `password = bal_window.get_wallet_password()`
|
||||||
|
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
|
||||||
|
means unencrypted wallet → proceed without password.
|
||||||
|
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
|
||||||
|
txid like export):
|
||||||
|
|
||||||
|
```
|
||||||
|
Tx 2 of 5 — a1b2…c3d1 (short txid)
|
||||||
|
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
|
||||||
|
┌ outputs ─────────────────────────────────┐
|
||||||
|
│ bc1q…heir1 0,042 BTC │
|
||||||
|
│ bc1q…willexec fee 0,00012 BTC │
|
||||||
|
│ bc1q…change 0,00988 BTC │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
|
||||||
|
[Sign & Next ▶] [Skip] [Cancel all]
|
||||||
|
```
|
||||||
|
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
|
||||||
|
style helpers already imported in the qt layer; value via
|
||||||
|
`bal_window.window.format_amount`).
|
||||||
|
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
|
||||||
|
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
|
||||||
|
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
|
||||||
|
3. **Sign & Next** → sign this single tx through a **refactored helper**
|
||||||
|
extracted from the loop body of `sign_transactions`
|
||||||
|
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
|
||||||
|
byte-equivalent; batch method calls the helper per iteration so existing
|
||||||
|
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
|
||||||
|
today; then advance.
|
||||||
|
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
|
||||||
|
already-signed txs remain in the wizard's local dict (still exportable —
|
||||||
|
confirmation dialog warns about skipped ones).
|
||||||
|
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Save signed file…] [Show QR…] [Close]
|
||||||
|
```
|
||||||
|
- *Save file* = existing JSON path: `export_meta_gui(window,
|
||||||
|
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
|
||||||
|
subset (same serializer as `export_json_file`, window.py:1605).
|
||||||
|
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
|
||||||
|
subset (the online machine can scan them straight into Merge).
|
||||||
|
- Nothing touches `self.willitems`/history (external-mode rule, F3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist (execution order — tick here when resuming work)
|
||||||
|
|
||||||
|
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
|
||||||
|
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
|
||||||
|
min-size guard; bad magic/version; missing middle frame; duplicate
|
||||||
|
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
|
||||||
|
qrcode capacities F15). Run:
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
|
||||||
|
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
|
||||||
|
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
|
||||||
|
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
|
||||||
|
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
|
||||||
|
audio-modem send button (§7).
|
||||||
|
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
|
||||||
|
incl. camera error paths, audio-modem receive button (local mirror of
|
||||||
|
`_recv`, `setText` sink replaced by a callback), plain-payload fast
|
||||||
|
path into the wizard.
|
||||||
|
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
|
||||||
|
(§8.3). Regression-gate: full batch sign still green
|
||||||
|
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
|
||||||
|
including new `tests/test_gui_qr_transfer.py`).
|
||||||
|
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
|
||||||
|
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
|
||||||
|
Phase 3 += dedicated views `BalQrExportPage.qml` /
|
||||||
|
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
|
||||||
|
"chunked streams deferred" note, rewrite R6 mitigation, add Android
|
||||||
|
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
|
||||||
|
CHANGELOG numbered entry 56 at END (house rule).
|
||||||
|
- [x] **P6** Release hygiene: `python3 build_zip.py` +
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||||
|
electrum.plugins.bal` + external-zip test; ruff (repo venv
|
||||||
|
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
|
||||||
|
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
|
||||||
|
Docs: document audio-modem as OPTIONAL channel — requires the
|
||||||
|
Electrum `audio_modem` plugin enabled plus `pip install amodem`
|
||||||
|
and libportaudio (not installed in the dev runtime env today, F22);
|
||||||
|
manual test matrix gains an audiomodem round-trip row (two machines,
|
||||||
|
default slowest bitrate) marked optional/skippable when hardware
|
||||||
|
unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
|
||||||
|
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
|
||||||
|
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
|
||||||
|
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
|
||||||
|
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
|
||||||
|
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
|
||||||
|
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
|
||||||
|
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
|
||||||
|
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
|
||||||
|
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Findings log (append-only)
|
||||||
|
|
||||||
|
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
|
||||||
|
(D6 amended live from "preview dialog" to "review+sign wizard").
|
||||||
|
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
|
||||||
|
guarantees) — both shaped §§4/7.
|
||||||
|
- 2026-08-25: owner requested an audio-modem transfer path → researched
|
||||||
|
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
|
||||||
|
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
|
||||||
|
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
|
||||||
|
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
|
||||||
|
the runtime env yet — feature is strictly optional.
|
||||||
379
QML_PLAN.md
Normal file
379
QML_PLAN.md
Normal 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) | ~600–800 lines Python |
|
||||||
|
| 3 views (offline signer page first) | ~900–1300 lines QML |
|
||||||
|
| 4 android | fork-side patches + build iteration (device-dependent) |
|
||||||
|
| 5 tests | ~500 lines |
|
||||||
|
| 6 release/docs | small |
|
||||||
|
|
||||||
|
Overall: comparable to a medium feature, dominated by Phase 3 UI polish and
|
||||||
|
Phase 4 device iteration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Findings log (append-only)
|
||||||
|
|
||||||
|
- **F12** — The airgap round trip already exists in the Qt GUI layer and can
|
||||||
|
be ported almost verbatim: `export_json_file()` (window.py:1605) exports
|
||||||
|
all will items as `{wid: WillItem.to_dict()}` JSON (marking them
|
||||||
|
`EXPORTED`); the import side goes through `merge_will()`
|
||||||
|
(window.py:1620), which carries operational statuses, combines partial
|
||||||
|
signatures via `tx.combine_with_other_psbt()` when txids match, substitutes
|
||||||
|
the tx otherwise, and recomputes validity locally without network.
|
||||||
|
Conclusion: no new transfer format is needed; the plan is to give this
|
||||||
|
logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML
|
||||||
|
all use one implementation.
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by
|
||||||
|
the owner.
|
||||||
|
- **D2** — Android = offline signing device; sign/import/export flow has
|
||||||
|
top priority.
|
||||||
|
- **D3** — BAL enabled by default in the custom APK.
|
||||||
|
- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10).
|
||||||
12
README.md
12
README.md
@@ -26,6 +26,7 @@ bal/ the installable Electrum plugin package
|
|||||||
│ ├── willexecutors.py
|
│ ├── willexecutors.py
|
||||||
│ ├── checkalive.py
|
│ ├── checkalive.py
|
||||||
│ ├── reminders.py
|
│ ├── reminders.py
|
||||||
|
│ ├── qrtransfer.py QR will-transfer wire format / chunk scheduler
|
||||||
│ └── input_rules.py
|
│ └── input_rules.py
|
||||||
├── cli/ headless command-line layer (no Qt)
|
├── cli/ headless command-line layer (no Qt)
|
||||||
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
│ ├── 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`
|
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||||
exists, then enable it from **Tools → Plugins**.
|
exists, then enable it from **Tools → Plugins**.
|
||||||
|
|
||||||
|
## Transfer a will with QR codes (or audio)
|
||||||
|
|
||||||
|
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||||
|
sequence of QR codes and imported on another device (**Import via QR**). The
|
||||||
|
export offers All / Valid / Valid-NC filters plus a QR size preset
|
||||||
|
(150–1800 bytes/frame); the import flow reviews and sign each transaction
|
||||||
|
one at a time, then proposes exporting the signed transactions. When
|
||||||
|
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||||
|
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||||
|
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the full wire-format spec.
|
||||||
|
|
||||||
## Command-line / headless usage
|
## Command-line / headless usage
|
||||||
|
|
||||||
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
||||||
|
|||||||
@@ -279,6 +279,13 @@ class BalPlugin(BasePlugin):
|
|||||||
# stay display-only outside the wizard unless the user opts in.
|
# stay display-only outside the wizard unless the user opts in.
|
||||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
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
|
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||||
# exported .ics calendar should contain. Each reminder becomes its own
|
# exported .ics calendar should contain. Each reminder becomes its own
|
||||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||||
|
|||||||
212
bal/core/qrtransfer.py
Normal file
212
bal/core/qrtransfer.py
Normal 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
|
||||||
@@ -1338,7 +1338,7 @@ class WillItem(Logger):
|
|||||||
self.tx = Will.get_tx_from_any(w["tx"])
|
self.tx = Will.get_tx_from_any(w["tx"])
|
||||||
self.heirs = w.get("heirs", None)
|
self.heirs = w.get("heirs", None)
|
||||||
self.we = w.get("willexecutor", 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.description = w.get("description", None)
|
||||||
self.time = w.get("time", None)
|
self.time = w.get("time", None)
|
||||||
self.change = w.get("change", None)
|
self.change = w.get("change", None)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from functools import partial
|
|||||||
from typing import Any, Callable, Mapping, Optional, Union
|
from typing import Any, Callable, Mapping, Optional, Union
|
||||||
|
|
||||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
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.amountedit import BTCAmountEdit
|
||||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||||
from electrum.gui.qt.my_treeview import MyTreeView
|
from electrum.gui.qt.my_treeview import MyTreeView
|
||||||
@@ -42,6 +43,7 @@ from electrum.gui.qt.util import (
|
|||||||
MessageBoxMixin,
|
MessageBoxMixin,
|
||||||
OkButton,
|
OkButton,
|
||||||
TaskThread,
|
TaskThread,
|
||||||
|
WaitingDialog,
|
||||||
WindowModalDialog,
|
WindowModalDialog,
|
||||||
char_width_in_lineedit,
|
char_width_in_lineedit,
|
||||||
getOpenFileName,
|
getOpenFileName,
|
||||||
|
|||||||
@@ -11,16 +11,35 @@ All modal/non-modal dialogs of the plugin.
|
|||||||
* BalBuildWillDialog - the central build/sign/push/broadcast flow.
|
* BalBuildWillDialog - the central build/sign/push/broadcast flow.
|
||||||
* WillDetailDialog - shows the full will tree for one wallet.
|
* WillDetailDialog - shows the full will tree for one wallet.
|
||||||
* WillExecutorDialog - manage the list of will-executor servers.
|
* 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,
|
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
|
the few list classes they reference are imported lazily inside the methods that
|
||||||
use them (see ``lists`` imports below).
|
use them (see ``lists`` imports below).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zlib
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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.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 .calendar import BalCalendarButton
|
||||||
from .common import (
|
from .common import (
|
||||||
_,
|
_,
|
||||||
@@ -42,13 +61,18 @@ from .common import (
|
|||||||
NoHeirsException,
|
NoHeirsException,
|
||||||
NoWillExecutorNotPresent,
|
NoWillExecutorNotPresent,
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
QTimer,
|
QTimer,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -57,16 +81,21 @@ from .common import (
|
|||||||
TxBroadcastError,
|
TxBroadcastError,
|
||||||
TxFeesChangedException,
|
TxFeesChangedException,
|
||||||
Util,
|
Util,
|
||||||
|
WaitingDialog,
|
||||||
Will,
|
Will,
|
||||||
WillExecutorFeeTooHighException,
|
WillExecutorFeeTooHighException,
|
||||||
WillExecutorNotPresent,
|
WillExecutorNotPresent,
|
||||||
WillExpiredException,
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
WillexecutorChangeException,
|
WillexecutorChangeException,
|
||||||
Willexecutors,
|
Willexecutors,
|
||||||
bring_to_front,
|
bring_to_front,
|
||||||
decimal_point_to_base_unit_name,
|
decimal_point_to_base_unit_name,
|
||||||
|
draw_qr,
|
||||||
|
export_meta_gui,
|
||||||
import_meta_gui,
|
import_meta_gui,
|
||||||
|
log_error,
|
||||||
partial,
|
partial,
|
||||||
pyqtSignal,
|
pyqtSignal,
|
||||||
read_QIcon_from_bytes,
|
read_QIcon_from_bytes,
|
||||||
@@ -76,6 +105,7 @@ from .common import (
|
|||||||
stop_thread,
|
stop_thread,
|
||||||
time,
|
time,
|
||||||
top_level_of,
|
top_level_of,
|
||||||
|
write_json_file,
|
||||||
)
|
)
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
WillSettingsWidget,
|
WillSettingsWidget,
|
||||||
@@ -2363,3 +2393,811 @@ class HeirsDialog(BalDialog, MessageBoxMixin):
|
|||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
event.accept()
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -667,7 +667,9 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
export_menu.addAction(_("All"), self.export_will)
|
export_menu.addAction(_("All"), self.export_will)
|
||||||
export_menu.addAction(_("Valid"), self.export_will_valid)
|
export_menu.addAction(_("Valid"), self.export_will_valid)
|
||||||
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
|
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"), self.import_will_into_details)
|
||||||
|
menu.addAction(_("Import via QR"), self.import_will_via_qr)
|
||||||
menu.addAction(_("Merge"), self.merge_will)
|
menu.addAction(_("Merge"), self.merge_will)
|
||||||
menu.addAction(_("Broadcast"), self.broadcast)
|
menu.addAction(_("Broadcast"), self.broadcast)
|
||||||
menu.addAction(_("Check"), self.check)
|
menu.addAction(_("Check"), self.check)
|
||||||
@@ -769,6 +771,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
def import_will_into_details(self):
|
def import_will_into_details(self):
|
||||||
self.bal_window.import_will_into_details()
|
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):
|
def merge_will(self):
|
||||||
self.bal_window.merge_will_ui()
|
self.bal_window.merge_will_ui()
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from electrum.plugin import hook
|
|||||||
from electrum.util import EventListener, event_listener
|
from electrum.util import EventListener, event_listener
|
||||||
from PyQt6.QtWidgets import QLayout
|
from PyQt6.QtWidgets import QLayout
|
||||||
|
|
||||||
|
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
_,
|
_,
|
||||||
_logger,
|
_logger,
|
||||||
@@ -531,6 +533,21 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
# users (BASIC and ADVANCED).
|
# users (BASIC and ADVANCED).
|
||||||
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
|
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
|
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||||
# (not a free-text field) bound to the USER_TYPE config:
|
# (not a free-text field) bound to the USER_TYPE config:
|
||||||
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
|
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
|
||||||
@@ -647,6 +664,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
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)
|
btn.clicked.connect(reset)
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
@@ -905,6 +926,25 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
)
|
)
|
||||||
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
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 #
|
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||||
# their factory defaults. It only resets the settings exposed by THIS #
|
# 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.HISTORY_LABEL, edit_history_label, "line"),
|
||||||
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||||
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||||
|
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
|
||||||
]
|
]
|
||||||
for cfg, widget, kind in resets:
|
for cfg, widget, kind in resets:
|
||||||
# Persist the default value back into the Electrum config.
|
# Persist the default value back into the Electrum config.
|
||||||
@@ -958,6 +999,10 @@ class Plugin(BalPlugin, EventListener):
|
|||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
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
|
# Re-sync the history-label field's enabled state after a reset: the
|
||||||
# reset restores SAVE_HISTORY to its default, so the field must
|
# reset restores SAVE_HISTORY to its default, so the field must
|
||||||
# follow the (default) checkbox state again.
|
# follow the (default) checkbox state again.
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ from .dialogs import (
|
|||||||
BalWizardDialog,
|
BalWizardDialog,
|
||||||
WillDetailDialog,
|
WillDetailDialog,
|
||||||
WillExecutorDialog,
|
WillExecutorDialog,
|
||||||
|
WillQrExportDialog,
|
||||||
|
WillQrImportDialog,
|
||||||
)
|
)
|
||||||
from .lists import HeirListWidget, PreviewList
|
from .lists import HeirListWidget, PreviewList
|
||||||
from .widgets import LockTimeWidget, PercAmountEdit
|
from .widgets import LockTimeWidget, PercAmountEdit
|
||||||
@@ -980,6 +982,13 @@ class BalWindow:
|
|||||||
return self.show_transaction_real(tx, parent=parent)
|
return self.show_transaction_real(tx, parent=parent)
|
||||||
|
|
||||||
def invalidate_will(self, will=None):
|
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):
|
def on_success(result):
|
||||||
if result:
|
if result:
|
||||||
self.show_message(
|
self.show_message(
|
||||||
@@ -1015,75 +1024,93 @@ class BalWindow:
|
|||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def sign_transactions(self, password, will=None, txids=None):
|
def sign_transactions(self, password, will=None, txids=None):
|
||||||
try:
|
try:
|
||||||
willitems = will if will is not None else self.willitems
|
willitems = will if will is not None else self.willitems
|
||||||
txs = {}
|
txs = {}
|
||||||
signed = None
|
signed = None
|
||||||
tosign = None
|
tosign = None
|
||||||
|
|
||||||
def get_message():
|
def get_message():
|
||||||
msg = ""
|
msg = ""
|
||||||
if signed:
|
if signed:
|
||||||
msg = _(f"signed: {signed}\n")
|
msg = _(f"signed: {signed}\n")
|
||||||
return msg + _(f"signing: {tosign}")
|
return msg + _(f"signing: {tosign}")
|
||||||
|
|
||||||
if txids is not None:
|
if txids is not None:
|
||||||
targets = [
|
targets = [
|
||||||
t for t in txids
|
t for t in txids
|
||||||
if t in willitems and willitems[t].get_status("VALID")
|
if t in willitems and willitems[t].get_status("VALID")
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
targets = Will.only_valid(willitems)
|
targets = Will.only_valid(willitems)
|
||||||
for txid in targets:
|
for txid in targets:
|
||||||
wi = willitems[txid]
|
wi = willitems[txid]
|
||||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
if wi.get_status("COMPLETE"):
|
||||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
# Already signed and complete: keep as-is (the single-tx
|
||||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
# helper short-circuits without touching the wallet).
|
||||||
# from the serialized form instead, which is exactly how the will
|
tx, _ = self._prepare_and_sign_tx(willitems, txid, password)
|
||||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
txs[txid] = tx
|
||||||
tx = Will.get_tx_from_any(str(wi.tx))
|
continue
|
||||||
if wi.get_status("COMPLETE"):
|
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
|
txs[txid] = tx
|
||||||
continue
|
except Exception:
|
||||||
tosign = txid
|
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:
|
try:
|
||||||
self.waiting_dialog.update(get_message())
|
txin.script_descriptor = change.script_descriptor
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
for txin in tx.inputs():
|
txin.is_mine = True
|
||||||
prevout = txin.prevout.to_json()
|
txin._TxInput__address = change.address
|
||||||
if prevout[0] in willitems:
|
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
txin._TxInput__value_sats = change.value
|
||||||
txin._trusted_value_sats = change.value
|
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
|
|
||||||
|
|
||||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||||
signed = tosign
|
if tx.is_complete():
|
||||||
# is_complete = False
|
wi.set_status("COMPLETE", True)
|
||||||
if tx.is_complete():
|
# Refresh the per-item signature counts from the freshly signed
|
||||||
# is_complete = True
|
# partial tx: at this point the signatures are still present
|
||||||
wi.set_status("COMPLETE", True)
|
# (before any finalization), so the will list can show the real
|
||||||
# Refresh the per-item signature counts from the freshly signed
|
# "added/required" count (e.g. "1/2" for a multisig).
|
||||||
# partial tx: at this point the signatures are still present
|
try:
|
||||||
# (before any finalization), so the will list can show the real
|
have, required = tx.signature_count()
|
||||||
# "added/required" count (e.g. "1/2" for a multisig).
|
wi.sigs_have = int(have)
|
||||||
try:
|
wi.sigs_required = int(required)
|
||||||
have, required = tx.signature_count()
|
except Exception as e:
|
||||||
wi.sigs_have = int(have)
|
_logger.debug(f"signature_count after signing failed: {e}")
|
||||||
wi.sigs_required = int(required)
|
return tx, True
|
||||||
except Exception as e:
|
|
||||||
_logger.debug(f"signature_count after signing failed: {e}")
|
|
||||||
txs[txid] = tx
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return txs
|
|
||||||
|
|
||||||
def get_wallet_password(self, message=None, parent=None):
|
def get_wallet_password(self, message=None, parent=None):
|
||||||
parent = self.window if not parent else parent
|
parent = self.window if not parent else parent
|
||||||
@@ -1620,6 +1647,52 @@ class BalWindow:
|
|||||||
self.show_error(str(e))
|
self.show_error(str(e))
|
||||||
raise 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):
|
def merge_will(self, imported):
|
||||||
"""Merge imported will items into the live will.
|
"""Merge imported will items into the live will.
|
||||||
|
|
||||||
@@ -1762,6 +1835,18 @@ class BalWindow:
|
|||||||
|
|
||||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
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):
|
def _load_will_file(self, path):
|
||||||
data = read_json_file(path)
|
data = read_json_file(path)
|
||||||
willitems = {}
|
willitems = {}
|
||||||
|
|||||||
327
tests/test_core_qr_transfer.py
Normal file
327
tests/test_core_qr_transfer.py
Normal 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")
|
||||||
343
tests/test_gui_qr_transfer.py
Normal file
343
tests/test_gui_qr_transfer.py
Normal 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")
|
||||||
Reference in New Issue
Block a user