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 3a9ee5adb9
commit 8647eee586
16 changed files with 3106 additions and 64 deletions

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)