feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests

This commit is contained in:
2026-09-14 09:11:50 -04:00
parent 9c4697c923
commit fb88d7540c
82 changed files with 11665 additions and 3116 deletions

View File

@@ -22,7 +22,6 @@ This module is imported lazily (only when a ``bal_*`` command actually runs),
so a missing wallet or a network-less daemon can still start Electrum.
"""
import copy
import json
import time
@@ -44,10 +43,11 @@ from ..core.checkalive import (
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
resolve_guard_threshold,
)
from ..core.heirs import Heirs, is_op_return_address
from ..core.plugin_base import BalConfig, BalPlugin
from ..core.util import Util
from ..core.util import Util, copy_structure
from ..core.will import Will, WillItem
from ..core.willexecutors import Willexecutors, is_onion_url, is_tor_active
@@ -315,6 +315,28 @@ class BalController:
executor.
"""
will = {}
# Drop stale wallet-LOCAL will placeholders (mirror of the GUI
# build_will) so their coins are available to this build.
Will.remove_stale_wallet_history(
self.wallet, self.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.
# Recompute it for the will being built (earliest future delivery among
# the CURRENT heirs), mirroring ``BalWindow.build_will``, so the
# anticipated dates pass the build filter.
_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.plugin.is_basic_mode(), self.will_settings,
built_locktime=_new_locktime,
)
self.willexecutors = Willexecutors.get_willexecutors(
self.plugin, update=False, task=False
)
@@ -346,11 +368,11 @@ class BalController:
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)
Will.update_will(self.willitems, will)
@@ -435,7 +457,13 @@ class BalController:
raise _user_facing(e) from e
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
if locktime < date_to_check:
threshold_ts = resolve_guard_threshold(
self.plugin.is_basic_mode(), self.will_settings
)
if threshold_ts is not None:
if locktime < threshold_ts:
raise UserFacingException(_("locktime is lower than threshold"))
elif locktime < date_to_check:
raise UserFacingException(_("locktime is lower than threshold"))
if not self.no_willexecutor:

1180
bal/core/animated_qr.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,54 @@ def resolve_date_to_check(
return threshold.to_timestamp()
def resolve_guard_threshold(
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
) -> float | None:
"""Resolve the "locktime is lower than threshold" guard's reference.
The guard compares the stored settings on ONE reference frame: the
delivery (``locktime``, kept as at the call site) against this threshold.
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the
built will's frozen tx locktime so that an unchanged will never reads as
expired -- this helper resolves the threshold from the **stored settings
alone**. Otherwise, when the stored relative locktime is shorter than the
frozen locktime of an old (still valid) built will (e.g. the delivery was
shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh
"1y" locktime against the old will's anchored threshold and wrongly fire,
even though locktime > threshold by the settings themselves.
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls
back to comparing the locktime against ``date_to_check`` (= now), so its
behaviour is unchanged.
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
as-is.
* ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning
"N days BEFORE the delivery"): the threshold is anchored to the locktime
resolved forward from *now* (the settings' own delivery reading, never a
built tx), keeping both sides of the comparison in the same reference
frame, as the settings widget displays it.
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
missing stored value).
"""
if is_basic_mode:
return None
threshold_raw = will_settings.get("threshold")
if threshold_raw is None:
return None
threshold = BalTimestamp(threshold_raw)
if threshold.unit is None:
return threshold.to_timestamp()
now_dt = (
datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None
)
locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool:

View File

@@ -59,7 +59,7 @@ from electrum.util import (
write_json_file,
)
from .util import Util
from .util import Util, copy_structure
from .willexecutors import Willexecutors
if TYPE_CHECKING:
@@ -321,40 +321,14 @@ def get_change_output(wallet, in_amount, out_amount, fee):
return out
def _json_safe(value, _path="heirs", _depth=0):
"""Return a JSON-serializable deep copy of *value*.
def _json_safe(value, _path="heirs"):
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
The wallet DB persists the heirs dict via ``json_db.put``, which calls
``copy.deepcopy`` on the value. If any nested element is a live runtime
object (e.g. one holding a ``threading.RLock``), deepcopy raises
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
"Build will" task fails.
To make persistence robust we coerce the structure to plain
JSON-compatible types (dict / list / str / int / float / bool / None).
Anything else is converted to ``str(value)`` and logged with its path so
the offending field can be identified, instead of crashing the task.
Kept so call sites that imported ``_json_safe`` directly keep working; the
actual implementation (a JSON-safe, deepcopy-free clone) lives in
``bal.core.util`` so every copy path shares one code base.
"""
# Primitive JSON scalars are kept as-is.
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, dict):
return {
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
for i, v in enumerate(value)
]
# Unexpected runtime object: do not let it reach deepcopy. Log where it
# was found so the real source can be fixed, then store a safe string.
_logger.error(
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
"value={!r}".format(_path, type(value).__name__, value)
)
return str(value)
return copy_structure(value, _path=_path)
class Heirs(dict, Logger):

View File

@@ -24,7 +24,7 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu
import json
import os
import platform
from datetime import date, datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from electrum import constants, json_db
from electrum.logging import get_logger
@@ -109,7 +109,9 @@ def get_will(x):
try:
# Electrum >= 4.8.0
from electrum.stored_dict import register_name as _electrum_register_name # pyright: ignore[reportMissingImports]
from electrum.stored_dict import (
register_name as _electrum_register_name, # pyright: ignore[reportMissingImports]
)
def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
@@ -279,6 +281,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

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

@@ -0,0 +1,304 @@
"""
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 ``BAL1<TTT><iii><flag>`` frames for
multi-QR export, and reassembles/validates them on import.
Wire format (v2, compact)
-------------------------
A frame is::
BAL1<TTT><iii><flag><payload>
* ``BAL1`` - magic + format era (4 chars).
* ``TTT`` - frame total as exactly 3 base36 digits (1-based, cap 46655).
* ``iii`` - frame index as exactly 3 base36 digits (1-based).
* ``flag`` - one char: ``Z`` (zlib + base64) or ``0`` (plain ASCII).
* ``payload`` - every other character of the frame; the payloads of all
frames, concatenated in index order, rebuild the transfer string.
The fixed 11-char header replaces the legacy ``BALQR1|N|i|flags|`` form
(same 5 pieces of information) without any pipe separator, so the whole
frame is scan-friendly and the overhead no longer grows with the frame
count. Legacy ``BALQR1|…`` frames are still accepted 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"
FLAG_PLAIN = "0"
# 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
# Legacy wire format (still imported); the exporter emits the v2 form below.
_FRAME_MAGIC_V1 = MAGIC + str(VERSION)
# Compact v2 wire format: fixed-width base36 count fields, no separators.
_FRAME_MAGIC_V2 = "BAL1"
_BASE36_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_BASE36_WIDTH = 3
_HEADER_V2_LEN = len(_FRAME_MAGIC_V2) + 2 * _BASE36_WIDTH + 1
_MAX_TOTAL = 36 ** _BASE36_WIDTH - 1
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 flag
of the frame header lets the importer reverse this automatically.
"""
return __compress("\n".join(tx_strings), enabled=compress)
def encode_transfer_best(tx_strings):
"""Encode ``tx_strings`` with the smaller of plain vs compressed form.
Returns ``(transfer_string, compressed: bool)``. Compressed wins only
when zlib + base64 really is shorter (best-of, never larger).
"""
joined = "\n".join(tx_strings)
plain = joined
compressed = __compress(joined, enabled=True)
if len(compressed) < len(plain):
return compressed, True
return plain, False
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 compact ``BAL1`` frames.
Every returned frame has the fixed 11-char v2 header followed by its
share of the payload, so each frame is at most ``chunk_size`` characters
long. ``compressed`` stamps 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, or when the transfer needs more than
:data:`_MAX_TOTAL` frames.
"""
flag = FLAG_COMPRESSED if compressed else FLAG_PLAIN
total = __compute_total(len(transfer_string), chunk_size)
budget = chunk_size - _HEADER_V2_LEN
frames = []
pos = 0
length = len(transfer_string)
for index in range(1, total + 1):
end = min(pos + budget, length)
frames.append(
_FRAME_MAGIC_V2
+ _base36(total)
+ _base36(index)
+ flag
+ transfer_string[pos:end]
)
pos = end
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.
Accepts both the legacy ``BALQR1|total|index|flags|payload`` form and
the compact ``BAL1<total><index><flag><payload>`` v2 form.
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).
"""
if frame.startswith(_FRAME_MAGIC_V2):
return _parse_v2(frame)
return _parse_v1(frame)
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 _base36(n):
"""Zero-padded :data:`_BASE36_WIDTH` base36 render of ``n``."""
if not 0 <= n <= _MAX_TOTAL:
raise QrTransferError("BAL QR part number out of range: {}".format(n))
chars = []
for _ in range(_BASE36_WIDTH):
chars.append(_BASE36_DIGITS[n % 36])
n //= 36
return "".join(reversed(chars))
def _base36_decode(text):
"""Inverse of :func:`_base36`; raises ``ValueError`` on bad input."""
if len(text) != _BASE36_WIDTH or any(c not in _BASE36_DIGITS for c in text):
raise ValueError(text)
n = 0
for c in text:
n = n * 36 + _BASE36_DIGITS.index(c)
return n
def _parse_v1(frame):
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_V1:
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 _parse_v2(frame):
if len(frame) < _HEADER_V2_LEN:
raise QrTransferError("Not a BAL will QR (bad frame structure)")
# Magic is length _FRAME_MAGIC_V2; the two base36 fields and the flag
# make up the rest of the fixed header.
offset = len(_FRAME_MAGIC_V2)
total_s = frame[offset : offset + _BASE36_WIDTH]
index_s = frame[offset + _BASE36_WIDTH : offset + 2 * _BASE36_WIDTH]
flag = frame[offset + 2 * _BASE36_WIDTH]
try:
total = _base36_decode(total_s)
index = _base36_decode(index_s)
except ValueError:
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from None
if total < 1 or not 1 <= index <= total:
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
if flag not in (FLAG_PLAIN, FLAG_COMPRESSED):
raise QrTransferError("Not a BAL will QR (unknown flags)")
payload = frame[_HEADER_V2_LEN:]
return total, index, flag == FLAG_COMPRESSED, payload
def __compute_total(transfer_len, chunk_size):
"""Smallest frame count whose budget covers the whole transfer string.
The v2 header is fixed-width, so the budget is constant and the count is
a plain ceiling division, capped at :data:`_MAX_TOTAL`.
"""
if chunk_size < MIN_CHUNK_SIZE:
raise QrTransferError(
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
)
budget = chunk_size - _HEADER_V2_LEN
if budget <= 0:
raise QrTransferError(
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
)
total = -(-transfer_len // budget)
if total < 1:
total = 1
if total > _MAX_TOTAL:
raise QrTransferError(
"BAL QR transfer demands too many frames: {}".format(total)
)
return total

View File

@@ -21,8 +21,11 @@ import bisect
from datetime import datetime, timedelta, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.logging import get_logger
from electrum.transaction import PartialTxOutput
_logger = get_logger(__name__)
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
# timestamp*.
@@ -35,6 +38,41 @@ from electrum.transaction import PartialTxOutput
LOCKTIME_THRESHOLD = 500000000
def copy_structure(value, _path="copy"):
"""Return a JSON-serializable deep copy of *value*.
This is the ad-hoc, deepcopy-free stand-in used every time the plugin needs
an independent copy of a plain-data structure (heirs dicts, will-executor
dicts, status tables). It recursively clones dict / list / tuple values
while leaving JSON scalars (str / int / float / bool / None) as-is.
If any nested element is a live runtime object (e.g. one holding a
``threading.RLock``), ``copy.deepcopy`` would raise
``TypeError: cannot pickle '_thread.RLock' object``; instead we coerce the
offending value to ``str(value)`` and log it with its path so the source
field can be identified, without crashing the caller.
"""
# Primitive JSON scalars are kept as-is.
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, dict):
return {
str(k): copy_structure(v, "{}[{!r}]".format(_path, k))
for k, v in value.items()
}
if isinstance(value, (list, tuple)):
return [
copy_structure(v, "{}[{}]".format(_path, i)) for i, v in enumerate(value)
]
# Unexpected runtime object: do not let it reach deepcopy. Log where it
# was found so the real source can be fixed, then store a safe string.
_logger.error(
"copy_structure: non-serializable value at {} (type={}); coercing to "
"str. value={!r}".format(_path, type(value).__name__, value)
)
return str(value)
class Util:
"""Namespace of static helpers (kept as a class to preserve the original
``Util.method(...)`` call sites used throughout the plugin)."""

View File

@@ -26,9 +26,9 @@ The status flags themselves (the source of truth) stay here; only the mapping
"status -> colour" now lives in the GUI layer. No behaviour changed.
"""
import copy
from datetime import datetime, timezone
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.i18n import _
from electrum.logging import Logger, get_logger
from electrum.transaction import (
@@ -45,7 +45,7 @@ from electrum.util import (
)
from .heirs import WillExecutorFeeTooHighException
from .util import Util
from .util import Util, copy_structure
from .willexecutors import Willexecutors
MIN_LOCKTIME = 1
@@ -143,7 +143,7 @@ class Will:
willitems = {}
for wid in will:
Will.add_info_from_will(will, wid, wallet)
willitems[wid] = WillItem(will[wid])
willitems[wid] = WillItem(will[wid], wallet=wallet)
will = willitems
errors = {}
for wid in will:
@@ -165,7 +165,7 @@ class Will:
outputs = will[wid].tx.outputs()
ow = will[wid]
ow.normalize_locktime(others_input)
will[wid] = WillItem(ow.to_dict())
will[wid] = ow.copy()
for i in range(0, len(outputs)):
Will.change_input(
@@ -465,7 +465,7 @@ class Will:
continue
utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend:
balance += inputs[utxo_str][0][2].value_sats()
balance += utxo.value_sats()
utxo_to_spend.append(utxo)
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0:
@@ -848,6 +848,48 @@ class Will:
except Exception as e:
_logger.error(f"save_valid_transactions_to_history failed: {e}")
@staticmethod
def remove_stale_wallet_history(wallet, history_label):
"""Delete wallet-LOCAL will transactions saved under ``history_label``.
``save_valid_transactions_to_history`` stores the not-yet-signed
inheritance txs into the wallet's local history; those local
placeholders nominally spend the coins they reference. When the will is
REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the
stale placeholders must be removed so the coins become available again
to the new build (see ``Util.get_available_utxos``). Only
wallet-local/future (non-broadcast) txs whose label matches the history
label template are removed; confirmed/broadcast history is never
touched. Returns the txids that were removed.
"""
if not wallet or not getattr(wallet, "adb", None):
return []
removed = []
for txid, label in Will._wallet_labels(wallet):
if not label or not Util._label_matches_history(label, history_label):
continue
try:
height = int(wallet.adb.get_tx_height(txid).height())
except Exception:
continue
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
continue
try:
wallet.adb.remove_transaction(txid)
removed.append(txid)
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
continue
try:
wallet.set_label(txid, None)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history purge: {e}")
return removed
@staticmethod
def _add_transaction_to_history(wallet, tx, txid):
"""Store *tx* into the wallet's local history via ``adb``.
@@ -1214,9 +1256,9 @@ class Will:
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
count_heirs += 1
if h not in heirs_found:
_logger.debug(f"heir: {h} not found")
raise HeirNotFoundException(h)
if h not in heirs_found:
_logger.debug(f"heir: {h} not found")
raise HeirNotFoundException(h)
if not count_heirs:
raise NoHeirsException("there are not valid heirs")
if self_willexecutor and no_willexecutor == 0:
@@ -1327,49 +1369,76 @@ class WillItem(Logger):
return self.STATUS[status][1]
def __init__(self, w, _id=None, wallet=None):
if isinstance(
w,
WillItem,
):
self.__dict__ = w.__dict__.copy()
self.STATUS = copy.deepcopy(w.STATUS)
self.heirs = copy.deepcopy(w.heirs) if w.heirs is not None else None
else:
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.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None)
self.children = w.get("Children", None)
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
for s in self.STATUS:
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
# Backward-compatibility migration (A2): the "PENDING" status was
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
# store the flag under the legacy "PENDING" key, so if that key is
# present and set, carry it over to "MEMPOOL". This way no state is
# lost when loading an older will. The new key always wins if both
# happen to be present.
if "MEMPOOL" not in w and w.get("PENDING"):
self.STATUS["MEMPOOL"][1] = True
if isinstance(w, WillItem):
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
# form and deserialize from there: the tx is re-parsed into a fresh
# object, STATUS is rebuilt from the clones below and heirs /
# will-executors are cloned recursively, so the copy shares no
# mutable state with the source. See also copy().
data = w.to_dict()
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
data["willexecutor"] = (
copy_structure(w.we) if w.we is not None else None
)
if not _id:
self._id = self.tx.txid()
else:
self._id = _id
_id = w._id
w = data
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") or ""
self.description = w.get("description", None)
self.time = w.get("time", None)
self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None)
self.children = w.get("Children", None)
self.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
for s in self.STATUS:
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
# Backward-compatibility migration (A2): the "PENDING" status was
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
# store the flag under the legacy "PENDING" key, so if that key is
# present and set, carry it over to "MEMPOOL". This way no state is
# lost when loading an older will. The new key always wins if both
# happen to be present.
if "MEMPOOL" not in w and w.get("PENDING"):
self.STATUS["MEMPOOL"][1] = True
if not _id:
self._id = self.tx.txid()
else:
self._id = _id
if not self._id:
self.status += "ERROR!!!"
self.valid = False
if not self._id:
self.status += "ERROR!!!"
self.valid = False
if wallet:
self.tx.add_info_from_wallet(wallet)
def copy(self, wallet=None):
"""Return an independent copy of this WillItem (no deepcopy).
The copy is produced by serializing this item and deserializing it:
the transaction is re-parsed, the STATUS table is rebuilt and
heirs / will-executors are cloned recursively, so the result shares no
mutable state with ``self``. Pass a ``wallet`` when the copy's tx
needs its address/value information restored
(``tx.add_info_from_wallet``).
"""
return WillItem(self, _id=self._id, wallet=wallet)
@staticmethod
def copy_status_table(status_table):
"""Clone a STATUS table (``{flag: [label, bool]}``) without deepcopy.
Both the outer dict and every inner ``[label, bool]`` list are new
objects, so mutating the returned table never affects the source.
"""
return {k: [label, value] for k, (label, value) in status_table.items()}
def to_dict(self):
out = {
"_id": self._id,
@@ -1383,6 +1452,8 @@ class WillItem(Logger):
"baltx_fees": self.tx_fees,
"sigs_required": self.sigs_required,
"sigs_have": self.sigs_have,
"Father": self.father,
"Children": self.children,
}
for key in self.STATUS:
try:

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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.

View File

@@ -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(

View File

@@ -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