28 KiB
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:
- QR codes (primary): export the valid inheritance transactions as a sequence of QR codes, and import them back on another machine with the camera;
- Audio modem (secondary, when Electrum's
audio_modemplugin 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_modemplugin 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.mdadding dedicated view specs (owner decision D1). No QML code in this feature.
Out of scope
- Implementing the QML frontend (gated behind
QML_PLAN.mdPhases 0–2). - Merging imported wills into the live wallet state (existing Merge flows stay unchanged).
- Broadcast of the reviewed transactions (user exports them; broadcasting remains an explicit action elsewhere).
- CLI/cmdline parity for QR transfer.
2. Owner decisions (locked)
| # | Decision |
|---|---|
| D1 | QML part = update QML_PLAN.md document only; implementation later. |
| D2 | Frames carry a small compact ASCII header (BAL1<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_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\nor|, 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 (000…ZZZ), representing total N and index i,1 ≤ i ≤ N ≤ 46655. Fixed width means a 3-digit count field costs the same for a 1-frame or a 46655-frame transfer.<F>: single flag char —0⇒ plain,Z⇒ zlib+base64 compressed.<payload>: the i-th slice oftransfer_string, exactlychunk_sizebytes 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_stringof §4.1 — no BAL frames (split_frames/parse_frameare 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)
-
bal/core/plugin_base.py: afterREBUILD_ON_CLOSE(~line 264) addself.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) -
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" QComboBoxfed fromCHUNK_PRESETS; item text e.g."Small — ~150 bytes/QR (low-res cameras)";currentIndexChanged→self.QR_CHUNK_SIZE.set(budget_bytes); initial index fromQR_CHUNK_SIZE.get()(fallback to nearest preset if the stored value was customized).HelpButtontext: 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).
- Label:
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 withexport_json_file, window.py:1607-1609) — only whenwillcame 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 ofQRCodeWidget(electrum/gui/qt/qrcodewidget.py:21-72) but constructingqrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)and painting viaelectrum.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 asqrcodewidget.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 butis_available()is False, show an info message pointing topip 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:
- 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_frameerrors →show_warning(_("Not a BAL will QR"));- first valid frame adopts
totaland 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-advancetarget_indexto the lowest missing index; refresh grid + counter.
- failure →
- 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 exposingsetText(str)that stores the text and invokes the shared post-receive continuation, then callplugin._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)): runsassemble+decode_transfer→list[str]; anyQrTransferErrorsurfaces asshow_errorand 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
VALIDare dropped and listed in a warning. Setwi.set_status("IMPORTED", True)on survivors (mirrorsimport_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:
-
Password once:
password = bal_window.get_wallet_password()(window.py:1088-1100). ReturnsFalseon cancel → abort wizard;Nonemeans unencrypted wallet → proceed without password. -
Per-transaction page (one
QStackedWidgetstep 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 viaTxOutput.get_ui_address_str()style helpers already imported in the qt layer; value viabal_window.window.format_amount). - Totals:
output_value()sum; fees viainput_value() - output_value()after resolving inputs withWill.add_info_from_will(will, wid, wallet)(F10);-1/unknown handled like widgets.py:1319-1324 (F9).
- Outputs from
-
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). UpdateCOMPLETE/sig-counts exactly as today; then advance. -
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).
-
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 asexport_json_file, window.py:1605). - Show QR =
WillQrExportDialogover[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).
- Save file = existing JSON path:
9. Checklist (execution order — tick here when resuming work)
- P0
bal/core/qrtransfer.py+ unit teststests/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_SIZEconfig var + settings-dialog row 16 (ø16) + reset kind (§6). Verify inQT_QPA_PLATFORM=offscreenGUI run. - P2 Export:
BalWindow.export_will_via_qr,get_audio_modem_pluginhelper,WillListmenu 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,setTextsink replaced by a callback), plain-payload fast path into the wizard. - P4 Wizard:
_prepare_and_sign_txrefactor +WillTxReviewSignDialog(§8.3). Regression-gate: full batch sign still green (tests/test_core_*.pyoffline batch;tests/test_gui_*.pybatch including newtests/test_gui_qr_transfer.py). - P5 Docs & QML plan sync: update
QML_PLAN.md— Phase 2 models +=BalQrTransferModel(thin QObject overbal.core.qrtransfer), Phase 3 += dedicated viewsBalQrExportPage.qml/BalQrImportPage.qml(slot grid +QRScanreuse), 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 venvvenv/bin/ruff) no NEW violations; pyright false-positive policy per AGENTS.md. Version bump only viamake-release.sh(owner-driven). Docs: document audio-modem as OPTIONAL channel — requires the Electrumaudio_modemplugin enabled pluspip install amodemand 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 isparent.setText(blob)(F21) → thin adapter object; and BAL must not chunk/compress on this channel (F23).amodemis NOT in the runtime env yet — feature is strictly optional.