Files
bal-electrum-plugin/PLAN_QR_TRANSFER.md

28 KiB
Raw Blame History

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 WillItems, validity-checked locally.
    • Post-capture flow (owner decision D6, amended): after capture completes there is NO read-only preview. Instead a review-and-sign wizard walks through every transaction one at a time showing outputs (address + amount), total outputs, total fees, signs it (wallet password asked once), and at the end proposes exporting the signed transactions (to file and/or back via QR).
  • Audio-modem channel (owner decision D7): when the Electrum audio_modem plugin is enabled and available, the export dialog gains a "Send via Audio Modem…" button and the import dialog a "Receive via Audio Modem…" button, reusing the same transfer string (no QR framing).
  • QML: document-only update to QML_PLAN.md adding dedicated view specs (owner decision D1). No QML code in this feature.

Out of scope

  • Implementing the QML frontend (gated behind QML_PLAN.md Phases 02).
  • Merging imported wills into the live wallet state (existing Merge flows stay unchanged).
  • Broadcast of the reviewed transactions (user exports them; broadcasting remains an explicit action elsewhere).
  • CLI/cmdline parity for QR transfer.

2. Owner decisions (locked)

# Decision
D1 QML part = update QML_PLAN.md document only; implementation later.
D2 Frames carry a small compact ASCII header (BAL1<total><index><flag>, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy BALQR1|N|i|flags| export removed; legacy import kept.
D3 Payload = serialized transaction strings only (str(wi.tx)), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like merge_single_transaction does.
D4 Compression (zlib+base64 over the whole payload) exported as best-of: encode_transfer_best ships compressed when it is shorter, plain otherwise; flag 0 = plain, Z = compressed. No user-facing checkbox.
D5 4 standard size presets: ~150 / ~400 / ~900 / ~1800 bytes of payload per QR (low-res cams → high-res cams). Error-correction level fixed M. Stored as plugin config default; selectable again inside the export dialog.
D6 After capture completes: review + sign each tx one at a time (show outputs, total outputs, total fees), then propose export of the signed txs (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer.
D7 Add an audio-modem transfer path gated on Electrum's audio_modem plugin being enabled and available (amodem importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — amodem handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at pip install amodem when enabled-but-broken); graceful degradation, never a hard dependency.

3. Verified facts (research done on the local checkouts)

All verified by reading source; references are file:line.

# Fact Where
F1 Export today: BalWindow.export_will() writes {wid: WillItem.to_dict()} JSON; subsets All/Valid/Valid-NC built in WillList bal/gui/qt/window.py:1605-1621, lists.py:666-669, 743-767
F2 Batch signer: BalWindow.sign_transactions(password, will, txids) loops valid txs, resolves input values from change prevouts (txin._trusted_value_sats …), calls wallet.sign_transaction, updates COMPLETE + signature counts window.py:1017-1086
F3 External-will signing already supported (will= param, nothing saved to live wallet/history) window.py:1443-1484
F4 Single-tx import precedent: merge_single_transaction wraps WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...) window.py:1715-1724
F5 Local validity recomputation recipe (no network): add_willtreeUtil.get_available_utxoscheck_invalidatedsearch_raicheck_signatures window.py:1678-1701
F6 Plugin config accessor pattern BalConfig; keys declared in ctor bal/core/plugin_base.py:130-154, 211-264
F7 Plugin settings dialog: grid rows 0..12, add_widget(grid,label,widget,row,help) + _make_reset_btn(cfgvar,widget,kind); ADVANCED-only rows wrapped with _hide_if_basic(...) bal/gui/qt/plugin.py:442-850 (rows at 677-850)
F8 BalDialog(parent, bal_plugin, title=None, icon=...) base class anchors to top-level window bal/gui/qt/dialogs.py:92-129
F9 Fee display precedent: fee = tx.input_value() - tx.output_value(), fee rate = fee / tx.estimated_size() bal/gui/qt/widgets.py:1319-1328
F10 Input-value resolution helper exists: Will.add_info_from_will(will, wid, wallet) sets trusted input values from sibling will change outputs bal/core/will.py:118-136
F11 str(tx) = tx.serialize(): raw hex for complete txs; PartialTransaction.serialize() → base64 PSBT. Both accepted by tx_from_any (= Will.get_tx_from_any). This is exactly how BAL persists/reloads txs today electrum/transaction.py:907, 2539; will.py:106-113; window.py:1041-1044
F12 QRCodeWidget exists but hardcodes ERROR_CORRECT_L → cannot satisfy D5/M; must render our own qrcode instance electrum/gui/qt/qrcodewidget.py:35-37
F13 Camera scanning one-shot API with OS-permission handling: scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str])); on Linux uses zbar CLI backend electrum/gui/qt/qrreader/__init__.py:47-64
F14 QR painting without PIL: draw_qr(qr, paint_device, ...) from electrum.gui.common_qt.util (what QRCodeWidget.paintEvent uses) electrum/gui/qt/qrcodewidget.py:63-72
F15 QR capacity sanity (byte mode, EC M): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the qrcode lib auto-picks the version qrcode lib
F16 build_zip.py walks the tree with os.walk → new .py files ship automatically build_zip.py:39-47
F17 QML fork already has QRImage.qml, QRScan.qml, ScanDialog.qml; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" electrum/gui/qml/components/ScanDialog.qml:8-9
F18 QML_PLAN.md currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral QML_PLAN.md:228-231, 304
F19 House test conventions: def test_* + if __name__ == "__main__" + sys.path.insert(0, ..pardir); run standalone or via pytest tests/test_core_heirs.py:1-24
F20 Fork ships an audio_modem plugin. _send(parent, blob) zlib-compresses an ASCII blob, plays it via speaker through amodem inside a WaitingDialog; bit-rate selectable in the plugin's own settings (default = amodem.config.slowest()) electrum/plugins/audio_modem/qt.py:96-110
F21 _recv(parent) records from mic and delivers the decompressed ASCII text by calling parent.setText(blob) — the only integration contract is "an object with setText(str)"; there is no callback API audio_modem/qt.py:112-127
F22 Plugin lookup for enabled plugins: window.plugins.get(name) → instance or None (Plugins.get, electrum/plugin.py:575-576); availability check is the plugin's own is_available() (imports amodem). amodem is NOT installed in the runtime env today → optional dependency (pip amodem + libportaudio); feature must degrade gracefully electrum/plugin.py:575, runtime-env check
F23 amodem.main.send/recv stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since _send compresses internally, BAL sends the plain transfer string to avoid double compression consequence of F20/F21

4. Wire format specification

4.1 Transfer string

transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
  • tx_str(tx) = str(tx) (F11): hex for complete txs, base64-PSBT for partially-signed ones. Neither alphabet contains \n or |, so both are safe delimiters.
  • Ordering: ascending tx.txid() → deterministic output for identical input.
  • If compression enabled (D4): transfer_string = base64_ascii( zlib_compress( transfer_string ) ).

4.2 Frame layout (one frame = content of ONE QR code)

Wire format v2 (compact, current export):

BAL1<TTT><iii><F><payload>
  • Magic+version literal BAL1 (reject anything else with a clear message).
  • <TTT> = <iii>base36 zero-padded 3-char strings (000ZZZ), representing total N and index i, 1 ≤ i ≤ N ≤ 46655. Fixed width means a 3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
  • <F>: single flag char — 0 ⇒ plain, Z ⇒ zlib+base64 compressed.
  • <payload>: the i-th slice of transfer_string, exactly chunk_size bytes each (last slice may be shorter). No separators: both base36 count fields are fixed-width, so the header is unambiguously 11 chars and the payload starts at offset 11.
  • Header overhead is a constant 11 bytes → effective payload = chunk_size 11; the chunker slices the transfer string so that header+payload ≤ preset size.

Legacy frames BALQR1|<total>|<index>|<flags>|<payload> (variable-width decimal header, pipe-separated) are still imported by parse_frame (_parse_v1), so old exports keep working; the exporter emits v2 only. That is the one deliberate compatibility break: v2 frames are not readable by builds older than this change.

4.3 Size presets (D5)

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:

MAGIC = "BALQR"
VERSION = 1
FLAG_COMPRESSED = "Z"
CHUNK_PRESETS = [(label_en, budget_bytes), ...]   # §4.3 table

def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
    """Join -> optional zlib+base64 -> return transfer_string."""

def split_frames(transfer_string: str, chunk_size: int) -> list[str]
    """Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
    chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
    ValueError if chunk_size < MIN_CHUNK_SIZE."""

def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
    """-> (transfer_string, compressed); ships the shorter of plain vs
    zlib+base64 so the export emits the densest frames."""

def parse_frame(frame: str) -> tuple[int, int, bool, str]
    """-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
    'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
    version/arity/non-numeric fields."""

def assemble(frames: dict[int, str]) -> str
    """Validate indices form exactly range(1..max_total) (taken from any
    frame header), concatenate payloads in order, decode flags ->
    transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""

def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
    """Inverse of encode_transfer -> list of tx strings."""

Plus exceptions QrTransferError(ValueError), MissingFramesError, InconsistentTotalError. All docstrings/comments English; ruff-clean (line-length 88, E501 ignored).


6. Settings (Qt)

  1. bal/core/plugin_base.py: after REBUILD_ON_CLOSE (~line 264) add

    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)"; currentIndexChangedself.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):

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:

def get_audio_modem_plugin(self):        # on BalWindow
    """Return the loaded audio_modem plugin if enabled AND available
    (amodem importable), else None. Never raises."""
    p = self.window.plugins.get("audio_modem")   # F22
    return p if p is not None and p.is_available() else None

7.3 WillQrExportDialog(BalDialog) (new class in dialogs.py)

Layout:

[Size ▾ Small/Medium/Large/XL]   ← compressed best-of automatically (no checkbox)
[            QR image           ]   ← BalQrImage (see below)
«i di N»        [◀ Prev] [Next ▶]
[Save current QR as PNG…]  [Send via Audio Modem…]  [Close]

Behaviour:

  • On any control change: rebuild split_frames(encode_transfer_best(...)), reset index to frame 1, refresh counter (owner requirement: "cambiare la risoluzione").
  • BalQrImage(QWidget) ≈ trimmed copy of QRCodeWidget (electrum/gui/qt/qrcodewidget.py:21-72) but constructing qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2) and painting via electrum.gui.common_qt.util.draw_qr (F12/F14). ~30 lines.
  • Prev/Next wrap or disable at ends (disable chosen: clearer).
  • PNG export optional convenience via existing getSaveFileName + QWidget.grab() (same trick as qrcodewidget.py:110).
  • Send via Audio Modem… (D7): shown only when bal_window.get_audio_modem_plugin() returns a usable instance (below); otherwise hidden. Handler: re-encode the payload plain (encode_transfer(tx_strings, compress=False)) and call the plugin's _send(parent=self, blob=transfer_string) — its own WaitingDialog owns progress/cancellation (F20). Tooltip when hidden is unnecessary; instead, if the plugin is enabled but is_available() is False, show an info message pointing to pip install amodem + portaudio (F22).

8. Qt import flow + review/sign wizard

8.1 Entry point

lists.py toolbar menu, next to Import/Merge (lines 670-671):

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:

  • Scanscan_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_transferlist[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)

  • 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
  • P1 Settings: QR_CHUNK_SIZE config var + settings-dialog row 16 (ø16) + reset kind (§6). Verify in QT_QPA_PLATFORM=offscreen GUI run.
  • P2 Export: BalWindow.export_will_via_qr, get_audio_modem_plugin helper, WillList menu action, WillQrExportDialog + BalQrImage, audio-modem send button (§7).
  • 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.
  • 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).
  • 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).
  • 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.