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:
2026-08-28 16:28:37 -04:00
parent 0bacbf1997
commit d288b553ef
16 changed files with 3106 additions and 64 deletions

View File

@@ -279,6 +279,13 @@ class BalPlugin(BasePlugin):
# stay display-only outside the wizard unless the user opts in.
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
# per QR frame when exporting/importing a will through the QR channel.
# The settings dialog offers the 4 standard presets of
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
# Default 150 (small QR, low-resolution cameras).
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
# exported .ics calendar should contain. Each reminder becomes its own
# VEVENT (its own date in the calendar). The dates are spread uniformly

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

@@ -0,0 +1,212 @@
"""
bal.core.qrtransfer
===================
GUI-free helpers for moving BAL will data between devices via QR codes or
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
Scope
-----
* converts will transactions into a compact ``transfer_string``
(newline-joined serialized transactions, optionally zlib + base64
compressed);
* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for
multi-QR export, and reassembles/validates them on import.
The audio-modem channel deliberately bypasses the framing helpers here
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
carries the whole transfer string in a single blob, so callers only use
:func:`encode_transfer` / :func:`decode_transfer`.
This module never imports Qt or any Electrum GUI code (house rule).
"""
from __future__ import annotations
import base64
import zlib
MAGIC = "BALQR"
VERSION = 1
FLAG_COMPRESSED = "Z"
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
# low-resolution cameras to high-resolution cameras (owner decision D5).
CHUNK_PRESETS = (
("Small - ~150 bytes/QR (low-res cameras)", 150),
("Medium - ~400 bytes/QR", 400),
("Large - ~900 bytes/QR", 900),
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
)
# Smallest allowed payload budget per frame, below which the frame header
# could consume the whole budget.
MIN_CHUNK_SIZE = 40
_FRAME_MAGIC = MAGIC + str(VERSION)
class QrTransferError(ValueError):
"""Base error for will QR / audio transfer processing."""
class MissingFramesError(QrTransferError):
"""Some frame indices of a multi-QR transfer are missing."""
def __init__(self, missing):
self.missing = list(missing)
super().__init__("Missing QR frames: {}".format(self.missing))
class InconsistentTotalError(QrTransferError):
"""Frames disagree about the advertised frame total."""
def encode_transfer(tx_strings, compress=False):
"""Join serialized transaction strings into a transfer string.
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
the whole bundle shrinks before being printed/scanned. The optional flags
of the frame header let the importer reverse this automatically.
"""
return __compress("\n".join(tx_strings), enabled=compress)
def decode_transfer(transfer_string, compressed):
"""Inverse of :func:`encode_transfer`.
Returns the list of serialized transaction strings; empty frames are
dropped so a trailing newline (or an empty payload) cannot produce an
empty trailing element.
"""
text = __decompress(transfer_string, enabled=compressed)
return [part for part in text.split("\n") if part]
def split_frames(transfer_string, chunk_size, compressed=False):
"""Split ``transfer_string`` into full ``BALQR`` frames.
Every returned frame is at most ``chunk_size`` characters long (header
included). ``compressed`` propagates the ``Z`` flag into every frame so
the importer knows how to reverse the encoding.
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
the header plus any payload.
"""
flags = FLAG_COMPRESSED if compressed else ""
total = __compute_total(len(transfer_string), chunk_size, flags)
frames = []
pos = 0
length = len(transfer_string)
for index in range(1, total + 1):
overhead = len(__frame_header(total, index, flags))
budget = chunk_size - overhead
end = min(pos + budget, length)
frames.append(__build_frame(total, index, flags, transfer_string[pos:end]))
pos = end
if pos >= length:
break
if pos < length:
# __compute_total guarantees this cannot happen; keep a safety net.
raise QrTransferError("internal error: frames did not cover the transfer string")
return frames
def parse_frame(frame):
"""Parse a single frame.
Returns ``(total, index, compressed: bool, payload: str)``. Raises
:class:`QrTransferError` on malformed input (bad magic/version, wrong
arity, non-integer or out-of-range frame numbers, unknown flags).
"""
parts = frame.split("|", maxsplit=4)
if len(parts) != 5:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
magic_seen, total_s, index_s, flags, payload = parts
if magic_seen != _FRAME_MAGIC:
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
try:
total = int(total_s)
index = int(index_s)
except ValueError as e:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flags not in ("", FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
return total, index, flags == FLAG_COMPRESSED, payload
def assemble(frames, total):
"""Concatenate frame payloads back into a transfer string.
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
be present (else :class:`MissingFramesError`) and no index may exceed
``total`` (else :class:`InconsistentTotalError`).
"""
if total < 1:
raise QrTransferError("invalid frame total")
missing = [index for index in range(1, total + 1) if index not in frames]
if missing:
raise MissingFramesError(missing)
extra = [index for index in frames if index > total]
if extra:
raise InconsistentTotalError()
return "".join(frames[index] for index in range(1, total + 1))
def preset_index_for_chunk_size(chunk_size):
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
diff = abs(chunk_size - budget)
if diff < best_diff:
best, best_diff = index, diff
return best
# --------------------------------------------------------------------------- #
# Internals
# --------------------------------------------------------------------------- #
def __compress(text, *, enabled):
if not enabled:
return text
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
def __decompress(text, *, enabled):
if not enabled:
return text
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
def __frame_header(total, index, flags):
return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags)
def __build_frame(total, index, flags, payload):
return __frame_header(total, index, flags) + payload
def __compute_total(transfer_len, chunk_size, flags):
"""Smallest frame count whose budget covers the whole transfer string.
The budget shrinks as ``total`` gains digits (wider header), so the count
is recomputed iteratively until it converges.
"""
if chunk_size < MIN_CHUNK_SIZE:
raise QrTransferError(
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
)
total = 1
while True:
overhead = len(__frame_header(total, total, flags))
budget = chunk_size - overhead
if budget <= 0:
raise QrTransferError(
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
)
if transfer_len <= budget * total:
return total
total += 1

View File

@@ -1338,7 +1338,7 @@ class WillItem(Logger):
self.tx = Will.get_tx_from_any(w["tx"])
self.heirs = w.get("heirs", None)
self.we = w.get("willexecutor", None)
self.status = w.get("status", None)
self.status = w.get("status") or ""
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)

View File

@@ -28,6 +28,7 @@ from functools import partial
from typing import Any, Callable, Mapping, Optional, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
from electrum.gui.common_qt.util import draw_qr
from electrum.gui.qt.amountedit import BTCAmountEdit
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
from electrum.gui.qt.my_treeview import MyTreeView
@@ -42,6 +43,7 @@ from electrum.gui.qt.util import (
MessageBoxMixin,
OkButton,
TaskThread,
WaitingDialog,
WindowModalDialog,
char_width_in_lineedit,
getOpenFileName,

View File

@@ -11,16 +11,35 @@ All modal/non-modal dialogs of the plugin.
* BalBuildWillDialog - the central build/sign/push/broadcast flow.
* WillDetailDialog - shows the full will tree for one wallet.
* WillExecutorDialog - manage the list of will-executor servers.
* WillQrExportDialog - export the will as a sequence of QR codes.
* WillQrImportDialog - capture/assemble a will from QR shots (or
audio) and send it to the review+sign wizard.
* WillTxReviewSignDialog - per-transaction review/sign wizard for the
imported will (external copy, never touches the live will).
To keep the dialogs verbatim while avoiding import cycles with the list views,
the few list classes they reference are imported lazily inside the methods that
use them (see ``lists`` imports below).
"""
import io
import zlib
from typing import TYPE_CHECKING
from ...core.checkalive import CheckAliveError
from ...core.checkalive import CheckAliveError, resolve_date_to_check
from ...core.reminders import build_ics_reminders
from ...core.qrtransfer import (
CHUNK_PRESETS,
MissingFramesError,
QrTransferError,
assemble,
decode_transfer,
encode_transfer,
parse_frame,
preset_index_for_chunk_size,
split_frames,
)
from .calendar import BalCalendarButton
from .common import (
_,
@@ -42,13 +61,18 @@ from .common import (
NoHeirsException,
NoWillExecutorNotPresent,
NotCompleteWillException,
QCheckBox,
QComboBox,
QDialog,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QTimer,
QVBoxLayout,
QWidget,
@@ -57,16 +81,21 @@ from .common import (
TxBroadcastError,
TxFeesChangedException,
Util,
WaitingDialog,
Will,
WillExecutorFeeTooHighException,
WillExecutorNotPresent,
WillExpiredException,
WillItem,
WillPostponedException,
WillexecutorChangeException,
Willexecutors,
bring_to_front,
decimal_point_to_base_unit_name,
draw_qr,
export_meta_gui,
import_meta_gui,
log_error,
partial,
pyqtSignal,
read_QIcon_from_bytes,
@@ -76,6 +105,7 @@ from .common import (
stop_thread,
time,
top_level_of,
write_json_file,
)
from .widgets import (
WillSettingsWidget,
@@ -2363,3 +2393,811 @@ class HeirsDialog(BalDialog, MessageBoxMixin):
def closeEvent(self, event):
event.accept()
# --------------------------------------------------------------------------- #
# QR / audio will transfer
# --------------------------------------------------------------------------- #
class BalQrImage(QWidget):
"""A widget that renders one QR code, scaled to its own size.
Electrum's own ``QRCodeWidget`` hard-codes the LOW error-correction level,
which is fine for a one-shot payload but risky for long multi-frame will
transfers. This widget renders a fresh code on every paint with MEDIUM
correction using Electrum's :func:`draw_qr` paint helper.
"""
def __init__(self, text="", parent=None):
QWidget.__init__(self, parent)
self.text = text
self.setMinimumSize(240, 240)
self.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
def set_text(self, text):
self.text = text
self.update()
def paintEvent(self, event):
# Imported lazily: qrcode is shipped with Electrum but it is not a Qt
# widget, so keeping it out of the hub import keeps dialogs importable
# even when qrcode itself is missing at import time.
import qrcode
qr = qrcode.QRCode(
error_correction=qrcode.ERROR_CORRECT_M, border=2
)
qr.add_data(self.text)
qr.make(fit=True)
draw_qr(
qr=qr, paint_device=self, is_enabled=True, min_boxsize=2
)
QWidget.paintEvent(self, event)
class WillQrExportDialog(BalDialog):
"""Export a will as a sequence of QR codes, one per screen.
The chosen transactions are serialized with the wire format of
:mod:`bal.core.qrtransfer` and split into frames of the configured chunk
size. The user walks the frames with Prev/Next arrows; the chunk size can
be changed live from the standard presets. An "Audio" send button is shown
when Electrum's ``audio_modem`` plugin is available (raw newline-joined
transactions, no BAL framing, since the audio transport zlib-compresses
internally).
"""
def __init__(self, bal_window, will=None, bal_plugin=None):
BalDialog.__init__(
self, bal_window.window, bal_plugin, _("Export will via QR codes")
)
self.bal_window = bal_window
try:
chunk = int(bal_window.bal_plugin.QR_CHUNK_SIZE.get())
except Exception:
chunk = CHUNK_PRESETS[0][1]
self.chunk_size = chunk
self._source = will if will is not None else bal_window.willitems
# Export filters: which will items the QR transfer includes. Mirrors
# the All / Valid / Valid-NC choices of the "Export" file menu.
self._filters = [
(_("All"), lambda wi: True),
(_("Valid"), lambda wi: wi.get_status("VALID")),
(
_("Valid NC"),
lambda wi: wi.get_status("VALID") and not wi.get_status("COMPLETE"),
),
]
self._filter_index = 0
self.auto_timer = QTimer(self)
self.auto_timer.timeout.connect(self._auto_step)
self._build_transfer(self._filtered_willitems())
if not self.tx_strings:
self.show_message(_("No will transaction to export."))
self.close()
return
self.index = 0
vbox = QVBoxLayout(self)
self.intro_label = QLabel()
self._update_intro()
vbox.addWidget(self.intro_label)
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel(_("Export:")))
self.filter_combo = QComboBox()
self.filter_combo.addItems([label for label, _fn in self._filters])
self.filter_combo.currentIndexChanged.connect(self._on_filter_change)
filter_row.addWidget(self.filter_combo)
filter_row.addStretch(1)
vbox.addLayout(filter_row)
self.qr_view = BalQrImage(parent=self)
self.qr_view.set_text(self.frames[self.index])
vbox.addWidget(self.qr_view)
self.progress_label = QLabel()
vbox.addWidget(self.progress_label)
nav = QHBoxLayout()
self.prev_btn = QPushButton(_("Previous"))
self.prev_btn.clicked.connect(self._prev)
nav.addWidget(self.prev_btn)
self.next_btn = QPushButton(_("Next"))
self.next_btn.clicked.connect(self._next)
nav.addWidget(self.next_btn)
nav.addStretch(1)
if bal_window.get_audio_modem_plugin() is not None:
audio_btn = QPushButton(_("Audio…"))
audio_btn.setToolTip(
_("Send the export over your speaker (Audio MODEM plugin).")
)
audio_btn.clicked.connect(self._audio_send)
nav.addWidget(audio_btn)
vbox.addLayout(nav)
auto_row = QHBoxLayout()
self.auto_btn = QPushButton(_("Auto"))
self.auto_btn.setToolTip(
_("Automatically advance through the QR codes.")
)
self.auto_btn.clicked.connect(self._toggle_auto)
auto_row.addWidget(self.auto_btn)
auto_row.addWidget(QLabel(_("QR codes per second:")))
self.fps_spin = QSpinBox()
self.fps_spin.setRange(1, 10)
self.fps_spin.setValue(1)
self.fps_spin.setSuffix(_(" /s"))
auto_row.addWidget(self.fps_spin)
self.loop_check = QCheckBox(_("Loop"))
self.loop_check.setToolTip(
_("When the last QR code is reached, keep cycling from the "
"first one instead of stopping.")
)
auto_row.addWidget(self.loop_check)
auto_row.addStretch(1)
vbox.addLayout(auto_row)
size_row = QHBoxLayout()
size_row.addWidget(QLabel(_("QR code size:")))
self.size_combo = QComboBox()
self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
self.size_combo.setCurrentIndex(
preset_index_for_chunk_size(self.chunk_size)
)
self.size_combo.currentIndexChanged.connect(self._on_chunk_change)
size_row.addWidget(self.size_combo)
size_row.addStretch(1)
vbox.addLayout(size_row)
close_btn = QPushButton(_("Close"))
close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
self._render()
def _build_transfer(self, willitems):
"""Serialize the chosen transactions into frame-ready state."""
items = sorted(willitems.values(), key=lambda wi: str(wi.tx.txid()))
self.tx_strings = [str(wi.tx) for wi in items]
self.audio_payload = "\n".join(self.tx_strings)
self.transfer = encode_transfer(self.tx_strings, compress=False)
self._refresh_frames()
self.total_frames = len(self.frames)
def _refresh_frames(self):
self.frames = split_frames(
self.transfer, self.chunk_size, compressed=False
)
self.index = 0
def _on_chunk_change(self, index):
self._stop_auto()
self.chunk_size = CHUNK_PRESETS[index][1]
self._refresh_frames()
self._render()
def _toggle_auto(self):
"""Start/stop the automatic QR slideshow."""
if self.auto_timer.isActive():
self._stop_auto()
return
if len(self.frames) <= 1:
return
fps = self.fps_spin.value()
if fps <= 0:
return
self.auto_btn.setText(_("Stop"))
self.auto_timer.start(int(1000 / fps))
def _stop_auto(self):
if self.auto_timer.isActive():
self.auto_timer.stop()
self.auto_btn.setText(_("Auto"))
def _auto_step(self):
if self.index >= len(self.frames) - 1:
# Reach the last code: loop back or stop the slideshow.
if self.loop_check.isChecked():
self.index = 0
self._render()
return
self._stop_auto()
return
self._next()
def _filtered_willitems(self):
"""The will items selected by the current export filter."""
_label, fn = self._filters[self._filter_index]
return {wid: wi for wid, wi in self._source.items() if fn(wi)}
def _update_intro(self):
self.intro_label.setText(
_(
"Scan the QR codes below, in order, with the will-opening "
"device.\nFrame 1 of {} carries the total number of codes."
).format(self.total_frames)
)
def _on_filter_change(self, index):
previous = self._filter_index
self._filter_index = index
self._stop_auto()
if not self._filtered_willitems():
# The selection is empty under the new filter: revert and inform.
self.filter_combo.blockSignals(True)
self.filter_combo.setCurrentIndex(previous)
self.filter_combo.blockSignals(False)
self._filter_index = previous
self.show_message(_("No will transaction matches the selected filter."))
return
self._build_transfer(self._filtered_willitems())
self._update_intro()
self._render()
def _prev(self):
if self.index > 0:
self.index -= 1
self._render()
def _next(self):
if self.index < len(self.frames) - 1:
self.index += 1
self._render()
def _render(self):
self.qr_view.set_text(self.frames[self.index])
self.progress_label.setText(
_("Frame {} of {}").format(self.index + 1, len(self.frames))
)
self.prev_btn.setEnabled(self.index > 0)
self.next_btn.setEnabled(self.index < len(self.frames) - 1)
def _audio_send(self):
try:
self.bal_window._audio_send_payload(self.audio_payload)
except Exception as e:
log_error(e, self)
self.show_error(str(e))
class WillQrImportDialog(BalDialog):
"""Import a will by scanning its QR codes (or receiving it by audio).
Frames are captured one by one from the camera (or typed manually). The
first frame fixes the total frame count and the transfer compression flag;
the slot grid shows which frames are still missing. When every frame is
present the "Review and Sign" button assembles the transfer, decodes it
into transactions and hands them to :class:`WillTxReviewSignDialog`. The
audio path is one-shot (no BAL framing) and jumps straight to the wizard.
All work happens on a local copy; the live will is never touched.
"""
def __init__(self, bal_window, bal_plugin=None):
BalDialog.__init__(
self, bal_window.window, bal_plugin, _("Import will via QR codes")
)
self.bal_window = bal_window
self.frames = {}
self.total = 0
self.compressed = False
self._scanning = False
self.slot_widgets = {}
vbox = QVBoxLayout(self)
intro = QLabel(
_(
"Scan the QR codes printed by the will-opening device, one "
"shot at a time.\nDuplicates are ignored; the first frame "
"sets the total number of codes."
)
)
intro.setWordWrap(True)
vbox.addWidget(intro)
self.status_label = QLabel(_("Waiting for the first frame…"))
vbox.addWidget(self.status_label)
# Slot grid inside a scroll area (a large will can need many frames).
self.slot_widget = QWidget()
self.slot_grid = QGridLayout(self.slot_widget)
self.slot_grid.setSpacing(4)
self.slot_area = QScrollArea()
self.slot_area.setWidget(self.slot_widget)
self.slot_area.setWidgetResizable(True)
self.slot_area.setMaximumHeight(180)
self.slot_area.setVisible(False)
vbox.addWidget(self.slot_area)
manual = QHBoxLayout()
self.manual_edit = QLineEdit()
self.manual_edit.setPlaceholderText(
_("…or paste/type the frame text here")
)
self.manual_edit.returnPressed.connect(self._add_from_manual)
manual.addWidget(self.manual_edit)
manual_btn = QPushButton(_("Add frame"))
manual_btn.clicked.connect(self._add_from_manual)
manual.addWidget(manual_btn)
vbox.addLayout(manual)
buttons = QHBoxLayout()
self.scan_btn = QPushButton(_("Scan QR with camera"))
self.scan_btn.clicked.connect(self._scan_camera)
buttons.addWidget(self.scan_btn)
if bal_window.get_audio_modem_plugin() is not None:
audio_btn = QPushButton(_("Receive by audio…"))
audio_btn.clicked.connect(self._audio_receive)
buttons.addWidget(audio_btn)
self.reset_btn = QPushButton(_("Reset"))
self.reset_btn.clicked.connect(self._reset_all)
buttons.addWidget(self.reset_btn)
buttons.addStretch(1)
vbox.addLayout(buttons)
bottom = QHBoxLayout()
self.review_btn = QPushButton(_("Review and Sign…"))
self.review_btn.setEnabled(False)
self.review_btn.clicked.connect(self._review_and_sign)
bottom.addWidget(self.review_btn)
bottom.addStretch(1)
close_btn = QPushButton(_("Close"))
close_btn.clicked.connect(self.close)
bottom.addWidget(close_btn)
vbox.addLayout(bottom)
# -- frame handling -------------------------------------------------------
def _add_from_manual(self):
text = self.manual_edit.text().strip()
if text:
self.manual_edit.clear()
self._add_frame(text)
def _add_frame(self, frame_text):
try:
total, index, compressed, payload = parse_frame(frame_text)
except QrTransferError as e:
self.show_error(str(e))
return
if self.total and total != self.total:
# A different total means a different transfer: wipe and restart.
self._reset_all()
self.show_warning(
_(
"The scanned code belongs to a different transfer ({} "
"frames). The import was reset; scan the first code again."
).format(total)
)
return
if not self.total:
self.total = total
self.compressed = compressed
self._init_slots()
self.frames[index] = payload
self._update_slots()
self._update_status()
def _init_slots(self):
while self.slot_grid.count():
item = self.slot_grid.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self.slot_grid.removeItem(item)
self.slot_widgets = {}
for index in range(1, self.total + 1):
b = QPushButton(str(index))
b.setEnabled(False)
row = (index - 1) // 8
col = (index - 1) % 8
self.slot_grid.addWidget(b, row, col)
self.slot_widgets[index] = b
self.slot_area.setVisible(True)
def _update_slots(self):
for index, b in self.slot_widgets.items():
present = index in self.frames
b.setStyleSheet(
"QPushButton{background-color:#90ee90;}" if present else ""
)
def _update_status(self):
have = len(self.frames)
if have >= self.total:
self.status_label.setText(_("All {} frames stored.").format(self.total))
self.review_btn.setEnabled(True)
else:
self.status_label.setText(
_("Stored {} of {} frames.").format(have, self.total)
)
self.review_btn.setEnabled(False)
def _reset_all(self):
self.frames = {}
self.total = 0
self.compressed = False
if self.slot_widgets:
for b in self.slot_widgets.values():
b.deleteLater()
self.slot_widgets = {}
self.slot_area.setVisible(False)
self.review_btn.setEnabled(False)
self.status_label.setText(_("Waiting for the first frame…"))
# -- capture --------------------------------------------------------------
def _scan_camera(self):
if self._scanning:
return
from electrum.gui.qt.qrreader import scan_qrcode_from_camera
self._scanning = True
self.scan_btn.setEnabled(False)
def callback(success, error, data):
self._scanning = False
self.scan_btn.setEnabled(True)
if success and data:
self._add_frame(data)
elif not success and error:
self.show_error(str(error))
try:
scan_qrcode_from_camera(
parent=self,
config=self.bal_window.window.config,
callback=callback,
)
except Exception as e:
self._scanning = False
self.scan_btn.setEnabled(True)
self.show_error(str(e))
def _audio_receive(self):
"""Receive a transfer over the audio_modem plugin (raw tx list).
The audio transport compresses internally and carries no BAL framing,
so this mirrors the plugin's own ``_recv`` (which hard-wires a
``setText`` sink) but delivers the decoded text through a callback.
"""
plugin = self.bal_window.get_audio_modem_plugin()
if plugin is None:
self.show_error(_("Audio MODEM plugin is not available."))
return
try:
import amodem # noqa: F401 # type: ignore (guaranteed by is_available)
except Exception as e:
self.show_error(str(e))
return
def receiver_thread():
with plugin._audio_interface() as interface:
src = interface.recorder()
dst = io.BytesIO()
amodem.main.recv(config=plugin.modem_config, src=src, dst=dst)
return dst.getvalue()
def on_success(blob):
if not blob:
return
try:
text = zlib.decompress(blob).decode("ascii")
except Exception as e:
self.show_error(str(e))
return
tx_strings = [
part for part in text.split("\n") if part and part.strip()
]
if not tx_strings:
self.show_error(_("No transaction data received."))
return
self._finish_import(tx_strings)
kbps = plugin.modem_config.modem_bps / 1e3
WaitingDialog(
self,
_("Waiting for audio ({:.1f} kbps)…").format(kbps),
receiver_thread,
on_success,
)
# -- finish ---------------------------------------------------------------
def _review_and_sign(self):
try:
transfer = assemble(self.frames, self.total)
tx_strings = decode_transfer(transfer, self.compressed)
except (MissingFramesError, QrTransferError) as e:
self.show_error(str(e))
return
if not tx_strings:
self.show_error(_("The transferred will contains no transactions."))
return
self._finish_import(tx_strings)
def _finish_import(self, tx_strings):
"""Build local WillItems and open the review+sign wizard."""
items = {}
for s in tx_strings:
try:
wi = WillItem({"tx": s}, wallet=self.bal_window.wallet)
except Exception as e:
self.show_error(
_("Could not parse a transferred transaction: {}").format(e)
)
return
items[wi._id] = wi
Will.normalize_will(items, self.bal_window.wallet)
self._local_validity_pass(items)
for wi in items.values():
wi.set_status("IMPORTED", True)
valid = [wid for wid in items if items[wid].get_status("VALID")]
if not valid:
self.show_error(
_(
"The imported will contains no valid transaction in this "
"wallet."
)
)
self.close()
return
self.close()
skipped = len(items) - len(valid)
if skipped:
self.show_warning(
_(
"{} imported transaction(s) are not valid in this wallet "
"and were skipped."
).format(skipped)
)
wizard = WillTxReviewSignDialog(
self.bal_window, will=items, bal_plugin=self.bal_plugin
)
if wizard.aborted:
return
show_on_top(wizard)
def _local_validity_pass(self, items):
"""Local, wallet-only validity check (no server, no expiry raise).
Mirrors the check that :meth:`BalWindow.merge_will` runs after a merge
so the import and the file-merge paths behave identically.
"""
date_to_check = getattr(self.bal_window, "date_to_check", None)
if date_to_check is None:
date_to_check = resolve_date_to_check(
self.bal_window.bal_plugin.is_basic_mode(),
self.bal_window.will_settings,
)
history_label = self.bal_window.bal_plugin.HISTORY_LABEL.get()
try:
Will.add_willtree(items)
all_utxos = Util.get_available_utxos(
self.bal_window.wallet,
history_label,
Will.get_min_locktime(items, default_value=date_to_check),
)
Will.check_invalidated(
items, Will.utxos_strs(all_utxos), self.bal_window.wallet
)
Will.search_rai(
Will.get_all_inputs(items, only_valid=True),
all_utxos,
items,
self.bal_window.wallet,
)
Will.check_signatures(items, self.bal_window.wallet)
except Exception as e:
log_error(e, self)
class WillTxReviewSignDialog(BalDialog):
"""Per-transaction review + sign wizard for an imported will.
Walks the (valid) imported transactions one at a time showing outputs,
total outputs and fees, with Sign / Skip / Cancel per page. All signing
runs on the local copy of the imported will; the live will and the wallet
history are never touched. The final page offers to export the signed
transactions as a file and/or as QR codes.
"""
def __init__(self, bal_window, will=None, bal_plugin=None):
BalDialog.__init__(
self, bal_window.window, bal_plugin, _("Review and sign imported will")
)
self.bal_window = bal_window
self.items = will if will is not None else bal_window.willitems
self.txids = sorted(Will.only_valid(self.items))
self.aborted = False
self.i = 0
if not self.txids:
self.aborted = True
self.close()
return
self.password = bal_window.get_wallet_password(
message=_(
"Enter your wallet password to sign the imported transactions."
)
)
if self.password is False:
self.aborted = True
self.close()
return
vbox = QVBoxLayout(self)
self.stack = QStackedWidget(self)
self.summary_page = self._build_summary_page()
# Index 0 = summary page; review pages start at index 1.
self.stack.addWidget(self.summary_page)
self.review_pages = []
for _txid in self.txids:
page = self._build_review_page()
self.stack.addWidget(page)
self.review_pages.append(page)
vbox.addWidget(self.stack)
self.stack.setCurrentIndex(1)
self._render()
# -- page builders ---------------------------------------------------------
def _build_review_page(self):
page = QWidget()
vbox = QVBoxLayout(page)
header = QLabel()
vbox.addWidget(header)
txid_label = QLabel()
txid_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
vbox.addWidget(txid_label)
outputs_label = QLabel()
outputs_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
vbox.addWidget(outputs_label)
totals_label = QLabel()
vbox.addWidget(totals_label)
row = QHBoxLayout()
sign_btn = QPushButton(_("Sign"))
sign_btn.clicked.connect(self._sign_current)
row.addWidget(sign_btn)
skip_btn = QPushButton(_("Skip"))
skip_btn.clicked.connect(self._advance)
row.addWidget(skip_btn)
cancel_btn = QPushButton(_("Cancel"))
cancel_btn.clicked.connect(self.close)
row.addWidget(cancel_btn)
row.addStretch(1)
vbox.addLayout(row)
return page
def _build_summary_page(self):
page = QWidget()
vbox = QVBoxLayout(page)
self.summary_label = QLabel()
self.summary_label.setWordWrap(True)
vbox.addWidget(self.summary_label)
save_btn = QPushButton(_("Save signed file…"))
save_btn.clicked.connect(self._save_signed)
vbox.addWidget(save_btn)
self.qr_btn = QPushButton(_("Show signed QR…"))
self.qr_btn.clicked.connect(self._show_signed_qr)
vbox.addWidget(self.qr_btn)
close_btn = QPushButton(_("Close"))
close_btn.clicked.connect(self.close)
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
return page
# -- rendering -------------------------------------------------------------
def _page_index(self):
return 0 if self.i >= len(self.txids) else self.i + 1
def _render(self):
if self.i >= len(self.txids):
self._enter_summary()
return
txid = self.txids[self.i]
wi = self.items[txid]
tx = wi.tx
page = self.review_pages[self.i]
headers = page.findChildren(QLabel)
headers[0].setText(
_("Transaction {} of {}").format(self.i + 1, len(self.txids))
)
headers[1].setText(_("TXID: {}").format(txid))
lines = []
for o in tx.outputs():
value = o.value if o.value is not None else _("unknown")
if isinstance(value, int):
value_s = self.bal_window.window.format_amount_and_units(value)
else:
value_s = value
lines.append("{} {}".format(o.get_ui_address_str(), value_s))
headers[2].setText("\n".join(lines) if lines else _("(no outputs)"))
total_out = sum(
(o.value or 0) for o in tx.outputs() if isinstance(o.value, int)
)
fee = None
try:
iv = tx.input_value()
if isinstance(iv, int):
fee = iv - total_out
except Exception:
fee = None
if fee is not None:
fee_s = self.bal_window.window.format_amount_and_units(fee)
else:
fee_s = _("unknown (partial transaction)")
headers[3].setText(
_("Total outputs: {}\nFee: {}").format(
self.bal_window.window.format_amount_and_units(total_out), fee_s
)
)
self.stack.setCurrentIndex(self._page_index())
def _enter_summary(self):
signed = sum(
1 for txid in self.txids if self.items[txid].get_status("COMPLETE")
)
self.summary_label.setText(
_(
"Signed {} of {} transactions.\n\nSave a signed file to carry "
"to the broadcast device, or show the signed transactions as "
"QR codes."
).format(signed, len(self.txids))
)
self.qr_btn.setEnabled(signed > 0)
self.stack.setCurrentIndex(0)
# -- actions ---------------------------------------------------------------
def _sign_current(self):
txid = self.txids[self.i]
try:
tx, newly = self.bal_window._prepare_and_sign_tx(
self.items, txid, self.password
)
except Exception as e:
log_error(e, self)
self.show_error(_("Could not sign the transaction: {}").format(e))
return
if newly and tx.is_complete():
self.items[txid].set_status("COMPLETE", True)
self._advance()
def _advance(self):
self.i += 1
self._render()
def _save_signed(self):
data = {wid: wi.to_dict() for wid, wi in self.items.items()}
def _do_save(path):
try:
write_json_file(path, data)
except Exception as e:
self.show_error(str(e))
return
self.show_message(_("Signed will saved."))
export_meta_gui(self.bal_window.window, "will_signed.json", _do_save)
def _show_signed_qr(self):
signed = {
wid: wi
for wid, wi in self.items.items()
if wid in self.txids and wi.get_status("COMPLETE")
}
if not signed:
self.show_message(_("No signed transaction to show."))
return
d = WillQrExportDialog(self.bal_window, will=signed, bal_plugin=self.bal_plugin)
show_on_top(d)

View File

@@ -667,7 +667,9 @@ class PreviewList(MyTreeView, MessageBoxMixin):
export_menu.addAction(_("All"), self.export_will)
export_menu.addAction(_("Valid"), self.export_will_valid)
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
export_menu.addAction(_("QR Codes"), self.export_will_via_qr)
menu.addAction(_("Import"), self.import_will_into_details)
menu.addAction(_("Import via QR"), self.import_will_via_qr)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check)
@@ -769,6 +771,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
def import_will_into_details(self):
self.bal_window.import_will_into_details()
def export_will_via_qr(self):
self.bal_window.export_will_via_qr()
def import_will_via_qr(self):
self.bal_window.import_will_via_qr()
def merge_will(self):
self.bal_window.merge_will_ui()

View File

@@ -19,6 +19,8 @@ from electrum.plugin import hook
from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from .common import (
_,
_logger,
@@ -531,6 +533,21 @@ class Plugin(BalPlugin, EventListener):
# users (BASIC and ADVANCED).
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
# Ordered low -> high so the user picks the resolution matching their
# camera. Visible to all users (BASIC and ADVANCED).
qr_size_combo = QComboBox()
qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
qr_size_combo.setCurrentIndex(
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
)
def on_qr_size_change(index):
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
# (not a free-text field) bound to the USER_TYPE config:
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
@@ -647,6 +664,10 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0
)
elif kind == "qr_size":
widget.setCurrentIndex(
preset_index_for_chunk_size(int(cfg.default))
)
btn.clicked.connect(reset)
return btn
@@ -905,6 +926,25 @@ class Plugin(BalPlugin, EventListener):
)
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
# size used when exporting a will via QR codes; changeable per export
# inside the export dialog itself.
lbl_qr_size = QLabel(_("QR Code Size"))
help_qr_size = HelpButton(
"Payload size of a single QR code when exporting a will via QR.\n\n"
"Larger QR codes hold more data (fewer shots) but are easier to "
"scan with a high-resolution camera; smaller QR codes scan fine "
"even with low-resolution cameras but require more shots.\n"
"The same selector is available inside the export dialog."
)
grid.addWidget(lbl_qr_size, 16, 0)
grid.addWidget(qr_size_combo, 16, 1)
grid.addWidget(help_qr_size, 16, 2)
reset_btn_qr_size = _make_reset_btn(
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
)
grid.addWidget(reset_btn_qr_size, 16, 3)
# ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS #
@@ -938,6 +978,7 @@ class Plugin(BalPlugin, EventListener):
(self.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
]
for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config.
@@ -958,6 +999,10 @@ class Plugin(BalPlugin, EventListener):
widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0
)
elif kind == "qr_size":
widget.setCurrentIndex(
preset_index_for_chunk_size(int(cfg.default))
)
# Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again.

View File

@@ -88,6 +88,8 @@ from .dialogs import (
BalWizardDialog,
WillDetailDialog,
WillExecutorDialog,
WillQrExportDialog,
WillQrImportDialog,
)
from .lists import HeirListWidget, PreviewList
from .widgets import LockTimeWidget, PercAmountEdit
@@ -980,6 +982,13 @@ class BalWindow:
return self.show_transaction_real(tx, parent=parent)
def invalidate_will(self, will=None):
# The reference timestamp is normally set by init_class_variables();
# fall back to "now" so a first-action invalidation always has it.
if not hasattr(self, "date_to_check") or self.date_to_check is None:
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
)
def on_success(result):
if result:
self.show_message(
@@ -1015,75 +1024,93 @@ class BalWindow:
self.waiting_dialog.exe()
def sign_transactions(self, password, will=None, txids=None):
try:
willitems = will if will is not None else self.willitems
txs = {}
signed = None
tosign = None
try:
willitems = will if will is not None else self.willitems
txs = {}
signed = None
tosign = None
def get_message():
msg = ""
if signed:
msg = _(f"signed: {signed}\n")
return msg + _(f"signing: {tosign}")
def get_message():
msg = ""
if signed:
msg = _(f"signed: {signed}\n")
return msg + _(f"signing: {tosign}")
if txids is not None:
targets = [
t for t in txids
if t in willitems and willitems[t].get_status("VALID")
]
else:
targets = Will.only_valid(willitems)
for txid in targets:
wi = willitems[txid]
# Do NOT deepcopy: the stored tx carries wallet-derived objects
# (utxo / script_descriptor) that hold a threading.RLock, and
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
# from the serialized form instead, which is exactly how the will
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
tx = Will.get_tx_from_any(str(wi.tx))
if wi.get_status("COMPLETE"):
if txids is not None:
targets = [
t for t in txids
if t in willitems and willitems[t].get_status("VALID")
]
else:
targets = Will.only_valid(willitems)
for txid in targets:
wi = willitems[txid]
if wi.get_status("COMPLETE"):
# Already signed and complete: keep as-is (the single-tx
# helper short-circuits without touching the wallet).
tx, _ = self._prepare_and_sign_tx(willitems, txid, password)
txs[txid] = tx
continue
tosign = txid
try:
self.waiting_dialog.update(get_message())
except Exception:
pass
tx, _signed = self._prepare_and_sign_tx(willitems, txid, password)
signed = tosign
txs[txid] = tx
continue
tosign = txid
except Exception:
return None
return txs
def _prepare_and_sign_tx(self, willitems, txid, password):
"""Prepare one will transaction and sign it.
Shared by the batch signer (:meth:`sign_transactions`) and the
per-transaction review wizard of the QR import flow
(:class:`WillTxReviewSignDialog`).
Returns ``(tx, newly_signed)``: ``newly_signed`` is False when the
transaction was already COMPLETE (nothing was signed).
"""
wi = willitems[txid]
# Do NOT deepcopy: the stored tx carries wallet-derived objects
# (utxo / script_descriptor) that hold a threading.RLock, and
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
# from the serialized form instead, which is exactly how the will
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
tx = Will.get_tx_from_any(str(wi.tx))
if wi.get_status("COMPLETE"):
return tx, False
for txin in tx.inputs():
prevout = txin.prevout.to_json()
if prevout[0] in willitems:
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
txin._trusted_value_sats = change.value
try:
self.waiting_dialog.update(get_message())
txin.script_descriptor = change.script_descriptor
except Exception:
pass
for txin in tx.inputs():
prevout = txin.prevout.to_json()
if prevout[0] in willitems:
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
txin._trusted_value_sats = change.value
try:
txin.script_descriptor = change.script_descriptor
except Exception:
pass
txin.is_mine = True
txin._TxInput__address = change.address
txin._TxInput__scriptpubkey = change.scriptpubkey
txin._TxInput__value_sats = change.value
txin.is_mine = True
txin._TxInput__address = change.address
txin._TxInput__scriptpubkey = change.scriptpubkey
txin._TxInput__value_sats = change.value
txin._trusted_value_sats = change.value
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
signed = tosign
# is_complete = False
if tx.is_complete():
# is_complete = True
wi.set_status("COMPLETE", True)
# Refresh the per-item signature counts from the freshly signed
# partial tx: at this point the signatures are still present
# (before any finalization), so the will list can show the real
# "added/required" count (e.g. "1/2" for a multisig).
try:
have, required = tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
except Exception as e:
_logger.debug(f"signature_count after signing failed: {e}")
txs[txid] = tx
except Exception:
return None
return txs
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
if tx.is_complete():
wi.set_status("COMPLETE", True)
# Refresh the per-item signature counts from the freshly signed
# partial tx: at this point the signatures are still present
# (before any finalization), so the will list can show the real
# "added/required" count (e.g. "1/2" for a multisig).
try:
have, required = tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
except Exception as e:
_logger.debug(f"signature_count after signing failed: {e}")
return tx, True
def get_wallet_password(self, message=None, parent=None):
parent = self.window if not parent else parent
@@ -1620,6 +1647,52 @@ class BalWindow:
self.show_error(str(e))
raise e
def export_will_via_qr(self, will=None):
"""Export the will (default: the live one) as QR codes on screen.
The selected transactions are serialized with the wire format of
:mod:`bal.core.qrtransfer` and shown, one frame at a time, in a
:class:`WillQrExportDialog`. When Electrum's ``audio_modem`` plugin
is available (:meth:`get_audio_modem_plugin`) the dialog also offers
an "Audio" send button.
"""
try:
willitems = will if will is not None else self.willitems
d = WillQrExportDialog(self, will=willitems, bal_plugin=self.bal_plugin)
show_on_top(d)
except Exception as e:
self.show_error(str(e))
raise e
def get_audio_modem_plugin(self):
"""Return Electrum's ``audio_modem`` plugin instance, or None.
The plugin is only usable when Electrum exposes it (the ``Plugins``
manager knows the name) and its optional runtime dependency
``amodem`` is installed (:meth:`is_available`). Every other case
returns None so callers can simply hide the audio buttons.
"""
try:
p = self.window.gui_object.plugins.get("audio_modem")
except Exception:
return None
if not p or not getattr(p, "is_available", lambda: False)():
return None
return p
def _audio_send_payload(self, payload):
"""Send a transfer payload through the audio_modem plugin.
Wraps the plugin's own ``_send`` with a proper parent widget. The
audio channel zlib-compresses internally, so the payload is passed
uncompressed (no BAL ``Z`` flag needed on that transport).
"""
plugin = self.get_audio_modem_plugin()
if plugin is None:
self.show_error(_("Audio MODEM plugin is not available."))
return
plugin._send(parent=self.window, blob=payload)
def merge_will(self, imported):
"""Merge imported will items into the live will.
@@ -1762,6 +1835,18 @@ class BalWindow:
import_meta_gui(self.window, _("will"), on_file, on_success)
def import_will_via_qr(self):
"""Import a will through QR codes (or audio) and review/sign it.
Opens a :class:`WillQrImportDialog`. The captured transactions are
parsed into fresh :class:`WillItem` objects (never touching the
live will), run through the same local validity pass the merge flow
uses, and are then presented in the per-transaction review wizard
(:class:`WillTxReviewSignDialog`).
"""
d = WillQrImportDialog(self, bal_plugin=self.bal_plugin)
show_on_top(d)
def _load_will_file(self, path):
data = read_json_file(path)
willitems = {}