feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests
This commit is contained in:
@@ -16,11 +16,10 @@ the Qt button and the OS/subprocess glue.
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from electrum.gui.qt.util import getSaveFileName
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||
|
||||
from electrum.gui.qt.util import getSaveFileName
|
||||
|
||||
from ...core.reminders import write_temp_ics
|
||||
from .common import _, _logger
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import subprocess
|
||||
@@ -28,6 +27,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 +42,7 @@ from electrum.gui.qt.util import (
|
||||
MessageBoxMixin,
|
||||
OkButton,
|
||||
TaskThread,
|
||||
WaitingDialog,
|
||||
WindowModalDialog,
|
||||
char_width_in_lineedit,
|
||||
getOpenFileName,
|
||||
@@ -80,6 +81,7 @@ from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAbstractSpinBox,
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
@@ -92,6 +94,7 @@ from PyQt6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
@@ -119,7 +122,7 @@ from ...core.heirs import (
|
||||
|
||||
# --- Core (GUI-free) logic layer ---
|
||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||
from ...core.util import Util
|
||||
from ...core.util import Util, copy_structure
|
||||
from ...core.will import (
|
||||
AmountException,
|
||||
HeirChangeException,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,15 +20,13 @@ from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
OP_RETURN_PREFIX,
|
||||
BalTimestamp,
|
||||
Buttons,
|
||||
CancelButton,
|
||||
HelpButton,
|
||||
MessageBoxMixin,
|
||||
MyTreeView,
|
||||
OP_RETURN_PREFIX,
|
||||
OkButton,
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
@@ -46,15 +44,17 @@ from .common import (
|
||||
QSpinBox,
|
||||
QStandardItem,
|
||||
QStandardItemModel,
|
||||
Qt,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
Qt,
|
||||
TaskThread,
|
||||
Util,
|
||||
Will,
|
||||
WillItem,
|
||||
Willexecutors,
|
||||
WillItem,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
enum,
|
||||
@@ -63,8 +63,8 @@ from .common import (
|
||||
import_meta_gui,
|
||||
is_op_return_address,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
server_status_text,
|
||||
server_status_tooltip,
|
||||
signature_suffix,
|
||||
@@ -663,11 +663,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
menu.addAction(_("Prepare"), self.build_transactions)
|
||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||
export_menu = menu.addMenu(_("Export"))
|
||||
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)
|
||||
menu.addAction(_("Import"), self.import_will_into_details)
|
||||
# Export/Import open a single window that offers all transports
|
||||
# (file / QR / audio). The Choose Filter / transport settings live
|
||||
# inside that window.
|
||||
menu.addAction(_("Export"), self.export_will)
|
||||
menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Merge"), self.merge_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
@@ -733,38 +733,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if will:
|
||||
self.update_will(will)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.will)
|
||||
|
||||
def export_will(self):
|
||||
self.bal_window.export_will()
|
||||
self.update()
|
||||
self.bal_window.export_will_dialog()
|
||||
|
||||
def export_will_valid(self):
|
||||
"""Export only the will items that are valid."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
|
||||
def export_will_valid_incomplete(self):
|
||||
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid, incomplete will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
def import_will(self):
|
||||
self.bal_window.import_will_dialog()
|
||||
|
||||
def import_will_into_details(self):
|
||||
self.bal_window.import_will_into_details()
|
||||
|
||||
@@ -19,9 +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,
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
EnterButton,
|
||||
@@ -38,6 +37,8 @@ from .common import (
|
||||
QWidget,
|
||||
UserCancelled,
|
||||
Willexecutors,
|
||||
_,
|
||||
_logger,
|
||||
add_widget,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
@@ -531,6 +532,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 +663,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 +925,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 +977,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 +998,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.
|
||||
|
||||
@@ -20,6 +20,7 @@ Contents:
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...core.heirs import get_op_return_hex, is_op_return_address
|
||||
from ...core.input_rules import (
|
||||
LockTimeEditor,
|
||||
normalize_locktime_raw_text,
|
||||
@@ -29,17 +30,15 @@ from ...core.input_rules import (
|
||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||
from .calendar import BalCalendar, BalCalendarButton
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
Any,
|
||||
BTCAmountEdit,
|
||||
BalTimestamp,
|
||||
ColorScheme,
|
||||
DECIMAL_POINT,
|
||||
Decimal,
|
||||
HelpButton,
|
||||
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||
NLOCKTIME_MAX,
|
||||
Any,
|
||||
BalTimestamp,
|
||||
BTCAmountEdit,
|
||||
ColorScheme,
|
||||
Decimal,
|
||||
HelpButton,
|
||||
Optional,
|
||||
QAbstractSpinBox,
|
||||
QCheckBox,
|
||||
@@ -57,13 +56,15 @@ from .common import (
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionFrame,
|
||||
Qt,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
Qt,
|
||||
Union,
|
||||
Util,
|
||||
Will,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
getSaveFileName,
|
||||
@@ -1331,14 +1332,28 @@ class WillWidget(QWidget):
|
||||
)
|
||||
detaillayout.addWidget(QLabel(""))
|
||||
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
|
||||
for heir in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' not in heir:
|
||||
decoded_amount = Util.decode_amount(
|
||||
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
||||
)
|
||||
for heir_name in self.will[w].heirs:
|
||||
if 'w!ll3x3c"' in heir_name:
|
||||
continue
|
||||
h = self.will[w].heirs[heir_name]
|
||||
decoded_amount = Util.decode_amount(
|
||||
h[3], self._bal_parent.decimal_point
|
||||
)
|
||||
if is_op_return_address(h[0]):
|
||||
data_hex = get_op_return_hex(h[0]) or ""
|
||||
try:
|
||||
decoded = bytes.fromhex(data_hex).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
except Exception:
|
||||
decoded = h[0]
|
||||
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
|
||||
else:
|
||||
detaillayout.addWidget(
|
||||
qlabel(
|
||||
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
||||
heir_name,
|
||||
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
||||
f"[{h[0]}]",
|
||||
)
|
||||
)
|
||||
if self.will[w].we:
|
||||
@@ -1354,6 +1369,10 @@ class WillWidget(QWidget):
|
||||
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
||||
)
|
||||
)
|
||||
if self.will[w].we.get("address"):
|
||||
detaillayout.addWidget(
|
||||
qlabel(_("Address"), self.will[w].we["address"])
|
||||
)
|
||||
detaillayout.addStretch()
|
||||
pal = QPalette()
|
||||
pal.setColor(
|
||||
|
||||
@@ -23,10 +23,10 @@ from ...core.checkalive import (
|
||||
CheckAliveError,
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
resolve_guard_threshold,
|
||||
)
|
||||
from .common import (
|
||||
_,
|
||||
_logger,
|
||||
OP_RETURN_PREFIX,
|
||||
AmountException,
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
@@ -40,10 +40,10 @@ from .common import (
|
||||
Mapping,
|
||||
Network,
|
||||
NoHeirsException,
|
||||
NoWillExecutorNotPresent,
|
||||
NotCompleteWillException,
|
||||
OP_RETURN_PREFIX,
|
||||
NoWillExecutorNotPresent,
|
||||
OkButton,
|
||||
Optional,
|
||||
PaymentIdentifier,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
@@ -57,15 +57,17 @@ from .common import (
|
||||
TxFeesChangedException,
|
||||
Util,
|
||||
Will,
|
||||
WillexecutorChangeException,
|
||||
WillExecutorFeeTooHighException,
|
||||
WillExecutorNotPresent,
|
||||
Willexecutors,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
WillexecutorChangeException,
|
||||
Willexecutors,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
copy,
|
||||
copy_structure,
|
||||
export_meta_gui,
|
||||
import_meta_gui,
|
||||
is_onion_url,
|
||||
@@ -73,8 +75,8 @@ from .common import (
|
||||
is_tor_active,
|
||||
log_error,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
show_on_top,
|
||||
shown_cv,
|
||||
time,
|
||||
@@ -88,6 +90,10 @@ from .dialogs import (
|
||||
BalWizardDialog,
|
||||
WillDetailDialog,
|
||||
WillExecutorDialog,
|
||||
WillExportDialog,
|
||||
WillImportDialog,
|
||||
_complete_import,
|
||||
decode_will_payload,
|
||||
)
|
||||
from .lists import HeirListWidget, PreviewList
|
||||
from .widgets import LockTimeWidget, PercAmountEdit
|
||||
@@ -461,6 +467,31 @@ class BalWindow:
|
||||
|
||||
def build_will(self, ignore_duplicate=True, keep_original=True):
|
||||
_logger.debug("building will...")
|
||||
# Drop stale wallet-LOCAL will placeholders saved by previous prepares
|
||||
# so their coins are available to this build (see remove_stale...).
|
||||
Will.remove_stale_wallet_history(
|
||||
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
# A (re)build may have anticipated the delivery (shorter heir recipes)
|
||||
# while ``date_to_check`` is still anchored to the OLD built will. Using
|
||||
# that stale anchor as the build filter would block every future
|
||||
# delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will
|
||||
# that is being built: its locktime is the earliest future delivery
|
||||
# among the CURRENT heirs. The checks of the EXISTING will keep their
|
||||
# anchored ``date_to_check`` (set in init_class_variables).
|
||||
_new_locktime = min(
|
||||
(
|
||||
Util.parse_locktime_string(h[2])
|
||||
for h in self.heirs.values()
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
if _new_locktime:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.bal_plugin.is_basic_mode(),
|
||||
self.will_settings,
|
||||
built_locktime=_new_locktime,
|
||||
)
|
||||
will = {}
|
||||
# willtodelete = []
|
||||
# willtoappend = {}
|
||||
@@ -515,11 +546,11 @@ class BalWindow:
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
|
||||
tx["status"] = _("New")
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["heirs"] = copy_structure(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
@@ -740,6 +771,27 @@ class BalWindow:
|
||||
|
||||
raise e
|
||||
|
||||
def is_locktime_below_threshold(self) -> bool:
|
||||
"""True when the stored settings make the delivery earlier than the
|
||||
Check Alive threshold (the "locktime is lower than threshold" guard).
|
||||
|
||||
Compares the delivery against the settings-derived threshold on the
|
||||
SAME reference frame (see ``resolve_guard_threshold``), never against
|
||||
the built-will-anchored ``date_to_check``: anchoring the guard to an
|
||||
old, longer built will would wrongly fire right after the delivery was
|
||||
shortened. The anchored reference still governs the validity and
|
||||
expiry checks, which is where ``date_to_check`` belongs.
|
||||
In BASIC mode there is no threshold, so the locktime is checked against
|
||||
``date_to_check`` (= now) exactly as before.
|
||||
"""
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
threshold_ts = resolve_guard_threshold(
|
||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
if threshold_ts is not None:
|
||||
return locktime < threshold_ts
|
||||
return self.date_to_check is not None and locktime < self.date_to_check
|
||||
|
||||
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
|
||||
try:
|
||||
_logger.info(
|
||||
@@ -752,6 +804,11 @@ class BalWindow:
|
||||
if not self.heirs:
|
||||
_logger.warning("not heirs {}".format(self.heirs))
|
||||
return
|
||||
# Free the coins locked by stale wallet-LOCAL will placeholders
|
||||
# BEFORE the amount/UTXO checks below (Step 1) see them.
|
||||
Will.remove_stale_wallet_history(
|
||||
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
|
||||
)
|
||||
try:
|
||||
self.init_class_variables()
|
||||
Will.check_amounts(
|
||||
@@ -786,8 +843,7 @@ class BalWindow:
|
||||
)
|
||||
)
|
||||
return
|
||||
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
|
||||
if locktime < self.date_to_check:
|
||||
if self.is_locktime_below_threshold():
|
||||
self.show_error(_("locktime is lower than threshold"))
|
||||
return
|
||||
if not self.no_willexecutor:
|
||||
@@ -980,6 +1036,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 +1078,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
|
||||
@@ -1611,6 +1692,19 @@ class BalWindow:
|
||||
else:
|
||||
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
|
||||
|
||||
def export_tx_file(self, path, will=None):
|
||||
"""Export only the serialized transactions of the given will items.
|
||||
|
||||
Writes a plain text file with every transaction (or PSBT) serialized
|
||||
on a single line, separated by a comma (``tx1,tx2,tx3``). The raw hex
|
||||
and PSBT base64 alphabets never contain a comma, so the separator is
|
||||
unambiguous. When ``will`` is omitted the live will items are used.
|
||||
"""
|
||||
willitems = will if will is not None else self.willitems
|
||||
serialized = ",".join(str(wi.tx) for wid, wi in willitems.items())
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(serialized)
|
||||
|
||||
def export_will(self, will=None):
|
||||
try:
|
||||
export_meta_gui(
|
||||
@@ -1620,6 +1714,73 @@ class BalWindow:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def export_will_dialog(self, will=None, initial_mode: Optional[str] = None):
|
||||
"""Open the unified export window (File / QR / Audio).
|
||||
|
||||
The window lets the user pick an All / Valid / Valid NC filter in
|
||||
the top row and choose one of the three transports, each with its
|
||||
contextual settings (file format for File, QR-code size and autoplay
|
||||
for QR, KB/sec for Audio). ``will`` defaults to the live will items;
|
||||
``initial_mode`` opens the window directly on the given transport.
|
||||
"""
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
d = WillExportDialog(
|
||||
self,
|
||||
will=willitems,
|
||||
bal_plugin=self.bal_plugin,
|
||||
initial_mode=initial_mode or "file",
|
||||
)
|
||||
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 set_audio_modem_bitrate(self, kbps):
|
||||
"""Set the ``audio_modem`` plugin transfer speed to ``kbps`` KB/sec.
|
||||
|
||||
Both the send and the receive paths read ``modem_config``, so the
|
||||
sender and the receiver must be configured with the same speed. Raises
|
||||
when the plugin (or its ``amodem`` dependency) is unavailable.
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
raise Exception(_("Audio MODEM plugin is not available."))
|
||||
try:
|
||||
import amodem.config
|
||||
except Exception as e:
|
||||
raise Exception(str(e)) from e
|
||||
plugin.modem_config = amodem.config.bitrates[int(kbps)]
|
||||
|
||||
def merge_will(self, imported):
|
||||
"""Merge imported will items into the live will.
|
||||
|
||||
@@ -1743,16 +1904,34 @@ class BalWindow:
|
||||
|
||||
def on_file(path):
|
||||
try:
|
||||
willitems = self._load_will_file(path)
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except Exception as e:
|
||||
self.show_error(_("Invalid will file: {}").format(e))
|
||||
return
|
||||
# Attach wallet/input info so the imported txs can be signed and
|
||||
# broadcast (mirrors what merge_will_from_file does).
|
||||
Will.normalize_will(willitems, self.wallet)
|
||||
for wi in willitems.values():
|
||||
wi.set_status("IMPORTED", True)
|
||||
imported.update(willitems)
|
||||
kind, data = decode_will_payload(text)
|
||||
try:
|
||||
if kind == "will":
|
||||
willitems = self._load_will_payload(data)
|
||||
# Attach wallet/input info so the imported txs can be
|
||||
# signed and broadcast (mirrors merge_will_from_file).
|
||||
Will.normalize_will(willitems, self.wallet)
|
||||
for wi in willitems.values():
|
||||
wi.set_status("IMPORTED", True)
|
||||
imported.update(willitems)
|
||||
else:
|
||||
# Serialized transactions: route through the shared import
|
||||
# tail (validity pass + review/sign wizard).
|
||||
_complete_import(
|
||||
self,
|
||||
self.bal_plugin,
|
||||
text,
|
||||
show_error=self.show_error,
|
||||
show_warning=self.show_warning,
|
||||
close=lambda: None,
|
||||
)
|
||||
except Exception as e:
|
||||
self.show_error(_("Invalid will file: {}").format(e))
|
||||
|
||||
def on_success():
|
||||
if not imported:
|
||||
@@ -1762,6 +1941,18 @@ class BalWindow:
|
||||
|
||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
||||
|
||||
def import_will_dialog(self):
|
||||
"""Open the unified import window (File / QR / Audio).
|
||||
|
||||
The window offers three transports: File opens the read-only
|
||||
:class:`WillDetailDialog` preview; QR and Audio capture the
|
||||
transfer and send it through the per-transaction review wizard
|
||||
(:class:`WillTxReviewSignDialog`). Every flow works on fresh
|
||||
:class:`WillItem` objects and never touches the live will.
|
||||
"""
|
||||
d = WillImportDialog(self, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
|
||||
def _load_will_file(self, path):
|
||||
data = read_json_file(path)
|
||||
willitems = {}
|
||||
@@ -1770,6 +1961,15 @@ class BalWindow:
|
||||
willitems[k] = WillItem(data[k], _id=k)
|
||||
return willitems
|
||||
|
||||
def _load_will_payload(self, data):
|
||||
"""Build WillItems from decoded whole-will JSON data."""
|
||||
willitems = {}
|
||||
for k, v in data.items():
|
||||
d = dict(v)
|
||||
d["tx"] = tx_from_any(d["tx"])
|
||||
willitems[k] = WillItem(d, _id=k)
|
||||
return willitems
|
||||
|
||||
def check_transactions_task(self, will):
|
||||
start = time.time()
|
||||
# Servers are now contacted in parallel (see
|
||||
|
||||
Reference in New Issue
Block a user