From b3624fef1cb50ad4d3e34bc951b9327872658472 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Wed, 9 Sep 2026 08:43:32 -0400 Subject: [PATCH] core+gui+cli: animated QR will transfer (balqr/UR1/UR2/BBQR codecs, audio channel, export/import wizard) --- bal/cli/controller.py | 7 +- bal/core/animated_qr.py | 1178 ++++++++++++++++++ bal/core/heirs.py | 40 +- bal/core/plugin_base.py | 6 +- bal/core/qrtransfer.py | 2 +- bal/core/util.py | 38 + bal/core/will.py | 112 +- bal/gui/qt/calendar.py | 3 +- bal/gui/qt/common.py | 5 +- bal/gui/qt/dialogs.py | 1420 +++++++++++++++++----- bal/gui/qt/lists.py | 63 +- bal/gui/qt/plugin.py | 5 +- bal/gui/qt/widgets.py | 18 +- bal/gui/qt/window.py | 134 +- pyproject.toml | 3 + tests/sim_update_flows.py | 8 +- tests/test_anticipate_manual_locktime.py | 6 +- tests/test_core_animated_qr.py | 478 ++++++++ tests/test_core_plugin_base.py | 10 +- tests/test_core_qr_transfer.py | 3 +- tests/test_core_will.py | 10 +- tests/test_core_will_invalidate.py | 5 +- tests/test_group_e_karen7_invalidate.py | 4 +- tests/test_group_e_mock_karen7.py | 8 +- tests/test_gui_export_dialogs.py | 456 +++++++ tests/test_gui_qr_transfer.py | 608 +++++++-- tests/test_heir_relative_anchor.py | 14 +- tests/test_import_will_details.py | 5 + tests/test_no_willexecutor_karen7.py | 6 +- tests/test_reproduce_none_type.py | 6 +- 30 files changed, 4037 insertions(+), 624 deletions(-) create mode 100644 bal/core/animated_qr.py create mode 100644 tests/test_core_animated_qr.py create mode 100644 tests/test_gui_export_dialogs.py diff --git a/bal/cli/controller.py b/bal/cli/controller.py index 1c3c8ee..dbd4e1c 100644 --- a/bal/cli/controller.py +++ b/bal/cli/controller.py @@ -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 @@ -47,7 +46,7 @@ from ..core.checkalive import ( ) 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 @@ -346,11 +345,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) diff --git a/bal/core/animated_qr.py b/bal/core/animated_qr.py new file mode 100644 index 0000000..64b30cf --- /dev/null +++ b/bal/core/animated_qr.py @@ -0,0 +1,1178 @@ +""" +bal.core.animated_qr +==================== + +GUI-free implementation of the interoperable animated-QR transfer formats +used to move BAL will data between devices. + +Supported wire formats (each self-describing and order-independent on +receive): + +* **BALQR** (native, unchanged): ``BALQR1|total|index|flags|payload``. +* **BC-UR v1** (BCR-2020-005 rev1 draft, May 2020):: + ur:bytes/1of7// + Fragments partition the BC32 rendering of the CBOR byte string; the + SHA-256 digest of the wrapped payload ties the parts together. +* **BC-UR v2** (BCR-2020-005 rev 2 / BCR-2020-012):: + ur:bytes/2-9/ + Fountain-coded parts; each part is a CBOR array + ``[seq_num, seq_len, message_len, checksum, data]`` whose CBOR bytes are + bytewords-minimal encoded with a trailing per-part CRC-32. The + ``checksum`` field holds the CRC-32 of the whole wrapped message, so the + parts are mixable and order-independent. +* **BBQR** (Coinkite):: + B$<2 base36 total><2 base36 index> + Equal-length text frames; the payload is uppercase hex, RFC-4648 + base32, or raw-deflate (``wbits=-10``) zlib plus base32. + +Everything is implemented from scratch on top of the Python standard library +only (``zlib``, ``hashlib``, ``base64``), so the shipped plugin zip stays a +self-contained bundle with no third-party dependencies (house rule). + +This module never imports Qt or any Electrum GUI code (house rule). +""" + +from __future__ import annotations + +import base64 +import hashlib +import zlib +from typing import Dict, FrozenSet, List, Optional, Sequence, Set, Tuple + +# --------------------------------------------------------------------------- # +# Errors & safety caps +# --------------------------------------------------------------------------- # + + +class AnimatedQrError(ValueError): + """Base error for all animated-QR codec failures.""" + + +class FormatNotDetectedError(AnimatedQrError): + """The scanned text does not look like any known animated-QR format.""" + + +class TransferConflictError(AnimatedQrError): + """An incoming frame belongs to a different transfer than the open one.""" + + +class SessionLimitError(AnimatedQrError): + """A receive session exceeded its safety caps.""" + + +class ChecksumError(AnimatedQrError): + """A part failed its checksum / digest validation.""" + + +# Safety caps for untrusted scanner input. +_MAX_SESSION_PARTS = 20000 +_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 + +# --------------------------------------------------------------------------- # +# CBOR minimals (byte-string envelope + the fountain part header) +# --------------------------------------------------------------------------- # + +_BYTE_STR_RES = 0x40 # byte string, length < 24 +_BYTE_STR_1 = 0x58 # byte string, 1-byte length +_BYTE_STR_2 = 0x59 # byte string, 2-byte length +_BYTE_STR_4 = 0x60 # byte string, 4-byte length +_ARRAY_RES = 0x80 +_UNSIGNED_RES = 0x00 + + +def cbor_byte_string(data: bytes) -> bytes: + """Wrap ``data`` in the minimal CBOR byte-string envelope (0x40..0x60).""" + n = len(data) + if n < 24: + head = bytes([_BYTE_STR_RES + n]) + elif n <= 0xFF: + head = bytes([_BYTE_STR_1, n]) + elif n <= 0xFFFF: + head = bytes([_BYTE_STR_2]) + n.to_bytes(2, "big") + elif n <= 0xFFFFFFFF: + head = bytes([_BYTE_STR_4]) + n.to_bytes(4, "big") + else: + raise AnimatedQrError("payload too large for the UR byte-string envelope") + return head + data + + +def unwrap_ur_cbor(message: bytes) -> bytes: + """Strip the CBOR byte-string envelope, falling back to the raw bytes. + + Receivers keep working even when the emitter embedded the payload without + any CBOR wrapping (some third-party ``ur:bytes`` emitters do). + """ + if not message: + raise AnimatedQrError("empty decoded message") + b0 = message[0] + if _BYTE_STR_RES <= b0 <= 0x57: + header_len, n = 1, b0 - _BYTE_STR_RES + elif b0 == _BYTE_STR_1 and len(message) >= 2: + header_len, n = 2, message[1] + elif b0 == _BYTE_STR_2 and len(message) >= 3: + header_len, n = 3, int.from_bytes(message[1:3], "big") + elif b0 == _BYTE_STR_4 and len(message) >= 5: + header_len, n = 5, int.from_bytes(message[1:5], "big") + else: + return message + if header_len + n != len(message): + raise AnimatedQrError("decoded message has an inconsistent CBOR length") + return message[header_len:] + + +def _cbor_unsigned(value: int) -> bytes: + if value < 24: + return bytes([_UNSIGNED_RES + value]) + if value <= 0xFF: + return bytes([0x18, value]) + if value <= 0xFFFF: + return bytes([0x19]) + value.to_bytes(2, "big") + if value <= 0xFFFFFFFF: + return bytes([0x1A]) + value.to_bytes(4, "big") + return bytes([0x1B]) + value.to_bytes(8, "big") + + +def cbor_part(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> bytes: + """The CBOR body of a BC-UR v2 fountain part (``[seq, seq_len, message_len, checksum, data]``).""" + out = bytearray([_ARRAY_RES + 5]) + out += _cbor_unsigned(seq_num) + out += _cbor_unsigned(seq_len) + out += _cbor_unsigned(message_len) + out += _cbor_unsigned(checksum) + out += cbor_byte_string(data) + return bytes(out) + + +def _need(buf: bytes, pos: int, count: int) -> None: + if pos + count > len(buf): + raise AnimatedQrError("truncated CBOR part header") + + +def _cbor_read_unsigned(buf: bytes, pos: int) -> Tuple[int, int]: + if pos >= len(buf): + raise AnimatedQrError("truncated CBOR part header") + octet = buf[pos] + if octet & 0xE0 != _UNSIGNED_RES: + raise AnimatedQrError("unexpected CBOR type in part header") + pos += 1 + additional = octet & 0x1F + if additional < 24: + return additional, pos + if additional == 24: + _need(buf, pos, 1) + return buf[pos], pos + 1 + if additional == 25: + _need(buf, pos, 2) + return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + if additional == 26: + _need(buf, pos, 4) + return int.from_bytes(buf[pos : pos + 4], "big"), pos + 4 + if additional == 27: + _need(buf, pos, 8) + return int.from_bytes(buf[pos : pos + 8], "big"), pos + 8 + raise AnimatedQrError("unsupported CBOR integer width in part header") + + +def _cbor_read_bytes(buf: bytes, pos: int) -> Tuple[bytes, int]: + if pos >= len(buf): + raise AnimatedQrError("truncated CBOR part header") + octet = buf[pos] + pos += 1 + if octet & 0xE0 != _BYTE_STR_RES: + raise AnimatedQrError("expected a CBOR byte string in part header") + additional = octet & 0x1F + if additional < 24: + n = additional + elif additional == 24: + _need(buf, pos, 1) + n, pos = buf[pos], pos + 1 + elif additional == 25: + _need(buf, pos, 2) + n, pos = int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + elif additional == 26: + _need(buf, pos, 4) + n, pos = int.from_bytes(buf[pos : pos + 4], "big"), pos + 4 + else: + raise AnimatedQrError("unsupported CBOR byte-string width in part header") + _need(buf, pos, n) + return buf[pos : pos + n], pos + n + + +def _cbor_read_array(buf: bytes, pos: int) -> Tuple[int, int]: + if pos >= len(buf): + raise AnimatedQrError("truncated CBOR part header") + octet = buf[pos] + pos += 1 + if octet & 0xE0 != _ARRAY_RES: + raise AnimatedQrError("expected a CBOR array in part header") + additional = octet & 0x1F + if additional < 24: + return additional, pos + if additional == 24: + _need(buf, pos, 1) + return buf[pos], pos + 1 + if additional == 25: + _need(buf, pos, 2) + return int.from_bytes(buf[pos : pos + 2], "big"), pos + 2 + raise AnimatedQrError("unsupported CBOR array header in part") + + +# --------------------------------------------------------------------------- # +# CRC-32 (same polynomial as ``zlib.crc32``, network byte order) +# --------------------------------------------------------------------------- # + + +def crc32_int(data: bytes) -> int: + """CRC-32 over ``data`` as an unsigned 32-bit integer.""" + return zlib.crc32(data) & 0xFFFFFFFF + + +def crc32_bytes(data: bytes) -> bytes: + """CRC-32 over ``data`` as 4 network-order (big-endian) bytes.""" + return crc32_int(data).to_bytes(4, "big") + + +# --------------------------------------------------------------------------- # +# Bytewords (BCR-2020-012) +# --------------------------------------------------------------------------- # + +_BYTEWORDS = ( + "ableacidalsoapexaquaarchatomauntawayaxisbackbaldbarnbeltbetabiasbluebodybragbr" + "ewbulbbuzzcalmcashcatschefcityclawcodecolacookcostcruxcurlcuspcyandarkdatadays" + "delidicedietdoordowndrawdropdrumdulldutyeacheasyechoedgeepicevenexamexiteyesfa" + "ctfairfernfigsfilmfishfizzflapflewfluxfoxyfreefrogfuelfundgalagamegeargemsgift" + "girlglowgoodgraygrimgurugushgyrohalfhanghardhawkheathelphighhillholyhopehornhu" + "tsicedideaidleinchinkyintoirisironitemjadejazzjoinjoltjowljudojugsjumpjunkjury" + "keepkenokeptkeyskickkilnkingkitekiwiknoblamblavalazyleaflegsliarlimplionlistlo" + "goloudloveluaulucklungmainmanymathmazememomenumeowmildmintmissmonknailnavyneed" + "newsnextnoonnotenumbobeyoboeomitonyxopenovalowlspaidpartpeckplaypluspoempoolpo" + "sepuffpumapurrquadquizraceramprealredorichroadrockroofrubyruinrunsrustsafesaga" + "scarsetssilkskewslotsoapsolosongstubsurfswantacotasktaxitenttiedtimetinytoilto" + "mbtoystriptunatwinuglyundouniturgeuservastveryvetovialvibeviewvisavoidvowswall" + "wandwarmwaspwavewaxywebswhatwhenwhizwolfworkyankyawnyellyogayurtzapszerozestzi" + "nczonezoom" +) + +_WORDS = [_BYTEWORDS[i : i + 4] for i in range(0, 1024, 4)] +_DIM = 26 +_WORD_LOOKUP: Optional[List[int]] = None + + +def _word_lookup() -> List[int]: + """First/last-letter lookup table (built lazily, mirrors Bytewords).""" + global _WORD_LOOKUP + if _WORD_LOOKUP is None: + table = [-1] * (_DIM * _DIM) + for i, word in enumerate(_WORDS): + x = ord(word[0]) - ord("a") + y = ord(word[3]) - ord("a") + table[y * _DIM + x] = i + _WORD_LOOKUP = table + return _WORD_LOOKUP + + +def _decode_word(word: str, word_len: int) -> int: + if len(word) != word_len: + raise AnimatedQrError("invalid bytewords word length") + x = ord(word[0]) - ord("a") + y = ord(word[3] if word_len == 4 else word[1]) - ord("a") + if not (0 <= x < _DIM and 0 <= y < _DIM): + raise AnimatedQrError("invalid bytewords characters") + value = _word_lookup()[y * _DIM + x] + if value == -1: + raise AnimatedQrError("invalid bytewords first/last pair") + if word_len == 4: + full = _WORDS[value] + if word[1] != full[1] or word[2] != full[2]: + raise AnimatedQrError("invalid bytewords middle letters") + return value + + +def bytewords_minimal_encode(data: bytes) -> str: + """BCR-2020-012 bytewords-minimal: one two-letter word per byte, then CRC.""" + crc = data + crc32_bytes(data) + return "".join(_WORDS[b][0] + _WORDS[b][3] for b in crc) + + +def bytewords_minimal_decode(text: str) -> bytes: + """Inverse of :func:`bytewords_minimal_encode` (validates the CRC-32).""" + if len(text) % 2: + raise AnimatedQrError("invalid bytewords length (odd)") + values = [_decode_word(text[i : i + 2], 2) for i in range(0, len(text), 2)] + payload = bytes(values) + if len(payload) < 5: + raise AnimatedQrError("bytewords payload too short") + body, checksum = payload[:-4], payload[-4:] + if crc32_bytes(body) != checksum: + raise AnimatedQrError("bytewords CRC-32 mismatch") + return body + + +# --------------------------------------------------------------------------- # +# BC32 (the deprecated bech32-derived codec used by BC-UR v1) +# --------------------------------------------------------------------------- # + +_BC32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_BC32_REV = {ch: i for i, ch in enumerate(_BC32_ALPHABET)} +_BECH32_GENERATOR = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + + +def bech32_polymod(values: Sequence[int]) -> int: + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ value + for i in range(5): + if (top >> i) & 1: + chk ^= _BECH32_GENERATOR[i] + return chk + + +def _bc32_checksum(values: List[int]) -> List[int]: + polymod = bech32_polymod([0] + values + [0] * 6) ^ 0x3FFFFFFF + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def _bc32_verify(values: List[int]) -> bool: + return bech32_polymod([0] + values) == 0x3FFFFFFF + + +def bc32_encode(data: bytes) -> str: + """BCR-2020-005 BC32: bech32 without the human-readable part and divider.""" + acc = 0 + bits = 0 + values: List[int] = [] + for byte in data: + acc = (acc << 8) | byte + bits += 8 + while bits >= 5: + bits -= 5 + values.append((acc >> bits) & 31) + if bits: + values.append((acc << (5 - bits)) & 31) + values += _bc32_checksum(values) + return "".join(_BC32_ALPHABET[v] for v in values) + + +def bc32_decode(text: str) -> bytes: + """Inverse of :func:`bc32_encode` (validates the 6-char checksum).""" + lowered = text.lower() + try: + values = [_BC32_REV[ch] for ch in lowered] + except KeyError: + raise AnimatedQrError("invalid BC-UR v1 character") from None + if len(values) < 6 or not _bc32_verify(values): + raise AnimatedQrError("invalid BC-UR v1 checksum") + data = values[:-6] + acc = 0 + bits = 0 + out = bytearray() + for value in data: + acc = (acc << 5) | value + bits += 5 + if bits >= 8: + bits -= 8 + out.append((acc >> bits) & 0xFF) + return bytes(out) + + +# --------------------------------------------------------------------------- # +# xoshiro256** + alias sampler (exact ports of the reference RNG chain) +# --------------------------------------------------------------------------- # + +_MASK64 = (1 << 64) - 1 + + +class _Xoshiro256: + """xoshiro256** 1.0, seeded via SHA-256 of a byte sequence.""" + + def __init__(self, seed: bytes): + digest = hashlib.sha256(seed).digest() + self._s = [ + int.from_bytes(digest[offset : offset + 8], "big") + for offset in range(0, 32, 8) + ] + + @staticmethod + def _rotl(x: int, k: int) -> int: + return ((x << k) | (x >> (64 - k))) & _MASK64 + + def next(self) -> int: + result = (self._rotl((self._s[1] * 5) & _MASK64, 7) * 9) & _MASK64 + t = (self._s[1] << 17) & _MASK64 + s = self._s + s[2] ^= s[0] + s[3] ^= s[1] + s[1] ^= s[2] + s[0] ^= s[3] + s[2] ^= t + s[3] = self._rotl(s[3], 45) + return result + + def next_double(self) -> float: + return self.next() / float(1 << 64) + + def next_int(self, low: int, high: int) -> int: + return int(self.next_double() * (high - low + 1)) + low + + +class _RandomAliasSampler: + """Vose's alias method, built in the exact order of the reference code.""" + + def __init__(self, probs: Sequence[float]): + total = sum(probs) + assert total > 0 + n = len(probs) + normalized = [p * float(n) / total for p in probs] + + small: List[int] = [] + large: List[int] = [] + for i in range(n - 1, -1, -1): + (small if normalized[i] < 1 else large).append(i) + + self._probs = [0] * n + self._aliases = [0] * n + while small and large: + a = small.pop() + g = large.pop() + self._probs[a] = normalized[a] + self._aliases[a] = g + normalized[g] += normalized[a] - 1 + (small if normalized[g] < 1 else large).append(g) + + while large: + self._probs[large.pop()] = 1 + while small: + self._probs[small.pop()] = 1 + + def next(self, rng: _Xoshiro256) -> int: + r1 = rng.next_double() + r2 = rng.next_double() + n = len(self._probs) + i = int(float(n) * r1) + return i if r2 < self._probs[i] else self._aliases[i] + + +def choose_fragments(seq_num: int, seq_len: int, checksum: int) -> Set[int]: + """The fragments mixed into a BC-UR v2 fountain part (reference seed math). + + Sequence numbers ``1..seq_len`` emit the pure fragment ``{seq_num - 1}``; + every larger sequence number deterministically mixes a pseudo-random + subset of fragments seeded by ``SHA256(seq ‖ checksum)``. + """ + if seq_num <= seq_len: + return {seq_num - 1} + seed = seq_num.to_bytes(4, "big") + checksum.to_bytes(4, "big") + rng = _Xoshiro256(seed) + probs: List[float] = [1.0 / i for i in range(1, seq_len + 1)] + degree = _RandomAliasSampler(probs).next(rng) + 1 + remaining = list(range(seq_len)) + shuffled: List[int] = [] + while remaining: + index = rng.next_int(0, len(remaining) - 1) + shuffled.append(remaining.pop(index)) + return set(shuffled[:degree]) + + +def _partition_message(message: bytes, fragment_len: int) -> List[bytes]: + fragments: List[bytes] = [] + for offset in range(0, len(message), fragment_len): + fragment = message[offset : offset + fragment_len] + if len(fragment) < fragment_len: + fragment += b"\x00" * (fragment_len - len(fragment)) + fragments.append(fragment) + return fragments + + +def _mix_fragments(fragments: Sequence[bytes], indexes: Set[int], fragment_len: int) -> bytes: + result = bytearray(fragment_len) + for index in indexes: + for i, byte in enumerate(fragments[index]): + result[i] ^= byte + return bytes(result) + + +# --------------------------------------------------------------------------- # +# BC-UR v2 (bytewords-minimal + fountain) +# --------------------------------------------------------------------------- # + + +def _ur2_header(seq_num: int, seq_len: int) -> str: + return "ur:bytes/{}-{}/".format(seq_num, seq_len) + + +def _ur2_part_string(seq_num: int, seq_len: int, message_len: int, checksum: int, data: bytes) -> str: + body = cbor_part(seq_num, seq_len, message_len, checksum, data) + return _ur2_header(seq_num, seq_len) + bytewords_minimal_encode(body) + + +def _ur2_part_cost(seq_num: int, seq_len: int, message_len: int, checksum: int, data_len: int) -> int: + body_len = len(cbor_part(seq_num, seq_len, message_len, checksum, b"\x00" * data_len)) + # bytewords_minimal_encode appends a 4-byte CRC over the body. + return len(_ur2_header(seq_num, seq_len)) + 2 * (body_len + 4) + + +def ur2_frames(payload: bytes, budget_chars: int) -> List[str]: + """Encode ``payload`` into BC-UR v2 fountain frames. + + ``budget_chars`` is the largest frame string the carrying QR code may + hold. The first ``seq_len`` frames are pure (one fragment each); a second + wave of ``seq_len`` mixed (fountain) frames follows so the receiver can + recover with a few parts still missing. + """ + message = cbor_byte_string(payload) + message_len = len(message) + checksum = crc32_int(message) + single_cost = len("ur:bytes/") + len(bytewords_minimal_encode(message)) + if single_cost <= budget_chars: + return ["ur:bytes/" + bytewords_minimal_encode(message)] + + fragment_len = message_len + fragment_count = 1 + while True: + seq_len = fragment_count + worst_seq = 2 * seq_len # the export loop emits up to 2*seq_len parts + cost = _ur2_part_cost(worst_seq, seq_len, message_len, checksum, fragment_len) + if cost <= budget_chars: + break + fragment_count += 1 + fragment_len = -(-message_len // fragment_count) + if fragment_count > message_len: + raise AnimatedQrError("QR budget too small for a BC-UR v2 part") + + fragments = _partition_message(message, fragment_len) + frames: List[str] = [] + for seq_num in range(1, seq_len + 1): + frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, fragments[seq_num - 1])) + for seq_num in range(seq_len + 1, 2 * seq_len + 1): + data = _mix_fragments(fragments, choose_fragments(seq_num, seq_len, checksum), fragment_len) + frames.append(_ur2_part_string(seq_num, seq_len, message_len, checksum, data)) + return frames + + +def ur2_parse_part(frame_text: str) -> Tuple[int, int, int, int, bytes]: + """Parse a BC-UR v2 part into ``(seq, seq_len, message_len, checksum, data)``.""" + frame_text = frame_text.strip().lower() + prefix = "ur:bytes/" + if not frame_text.startswith(prefix): + raise AnimatedQrError("not a BC-UR v2 part") + tail = frame_text[len(prefix) :] + if "/" not in tail: + body = bytewords_minimal_decode(tail) + return 1, 1, len(body), crc32_int(body), body + seq_head, words = tail.split("/", 1) + try: + seq_num_s, seq_len_s = seq_head.split("-", 1) + seq_num, seq_len = int(seq_num_s), int(seq_len_s) + except ValueError: + raise AnimatedQrError("bad BC-UR v2 sequence header") from None + if seq_len < 1 or not 1 <= seq_num <= 2**32 - 1: + raise AnimatedQrError("bad BC-UR v2 sequence numbers") + body = bytewords_minimal_decode(words) + arr, pos = _cbor_read_array(body, 0) + if arr != 5: + raise AnimatedQrError("bad BC-UR v2 part header arity") + seq_again, pos = _cbor_read_unsigned(body, pos) + seq_len_again, pos = _cbor_read_unsigned(body, pos) + message_len, pos = _cbor_read_unsigned(body, pos) + checksum, pos = _cbor_read_unsigned(body, pos) + data, pos = _cbor_read_bytes(body, pos) + if pos != len(body): + raise AnimatedQrError("trailing garbage in BC-UR v2 part header") + if seq_again != seq_num or seq_len_again != seq_len: + raise AnimatedQrError("BC-UR v2 part header mismatch") + return seq_num, seq_len, message_len, checksum, bytes(data) + + +# --------------------------------------------------------------------------- # +# BC-UR v1 (BCR-2020-005 rev1: BC32 fragments + SHA-256 digest) +# --------------------------------------------------------------------------- # + + +def _ur1_digest(message: bytes) -> str: + return bc32_encode(hashlib.sha256(message).digest()) + + +def _ur1_prefix(index: int, total: int, digest: str) -> str: + return "ur:bytes/{}{}/{}/".format( + index, "of{}".format(total), digest + ) + + +def ur1_frames(payload: bytes, budget_chars: int) -> List[str]: + """Encode ``payload`` into BC-UR v1 fragments (``NofM`` + BC32 + digest).""" + message = cbor_byte_string(payload) + digest = _ur1_digest(message) + full = bc32_encode(message) + + total = 1 + while True: + longest = _ur1_prefix(total, total, digest) + capacity = budget_chars - len(longest) + if capacity < 1: + raise AnimatedQrError("QR budget too small for BC-UR v1") + if len(full) <= capacity * total: + break + total += 1 + if total > _MAX_SESSION_PARTS: + raise AnimatedQrError("BC-UR v1 transfer demands too many parts") + + frames: List[str] = [] + pos = 0 + for index in range(1, total + 1): + prefix = _ur1_prefix(index, total, digest) + capacity = budget_chars - len(prefix) + frames.append(prefix + full[pos : pos + capacity]) + pos += capacity + return frames + + +def ur1_parse_part(frame_text: str) -> Tuple[int, int, str, str]: + """Parse a BC-UR v1 part into ``(index, total, digest, fragment)``. + + Accepts both the multipart form (``ur:bytes/NofM//``) and + the single-part form (``ur:bytes/``, no sequence header or digest). + """ + frame_text = frame_text.strip().lower() + prefix = "ur:bytes/" + if not frame_text.startswith(prefix): + raise AnimatedQrError("not a BC-UR v1 part") + tail = frame_text[len(prefix) :] + parts = tail.split("/") + if len(parts) == 1: + return 1, 1, "", parts[0] + if len(parts) != 3: + raise AnimatedQrError("bad BC-UR v1 part structure") + seq_head, digest, fragment = parts + if "of" not in seq_head: + raise AnimatedQrError("BC-UR v1 part misses the sequence header") + try: + index_s, total_s = seq_head.split("of", 1) + index, total = int(index_s), int(total_s) + except ValueError: + raise AnimatedQrError("bad BC-UR v1 sequence header") from None + if total < 1 or not 1 <= index <= total: + raise AnimatedQrError("bad BC-UR v1 sequence numbers") + if len(digest) != 58: + raise AnimatedQrError("bad BC-UR v1 digest") + return index, total, digest, fragment + + +# --------------------------------------------------------------------------- # +# BBQR (Coinkite) +# --------------------------------------------------------------------------- # + +_BBQR_PREFIX = "B$" + + +def _bbqr_base36(n: int) -> str: + if not 0 <= n <= 1295: + raise AnimatedQrError("BBQR part count out of range") + + def digit(x: int) -> str: + return chr(48 + x) if x < 10 else chr(65 + x - 10) + + return digit(n // 36) + digit(n % 36) + + +def _bbqr_base32(data: bytes) -> str: + return base64.b32encode(data).decode("ascii").rstrip("=") + + +def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]: + """Return ``(encoding, encoded_text, split_mod)`` honouring the reference.""" + if encoding == "H": + return "H", raw.hex().upper(), 2 + if encoding == "Z": + compressor = zlib.compressobj(wbits=-10) + compressed = compressor.compress(raw) + compressor.flush() + if len(compressed) < len(raw): + return "Z", _bbqr_base32(compressed), 8 + encoding = "2" + if encoding != "2": + raise AnimatedQrError("unknown BBQR encoding") + return "2", _bbqr_base32(raw), 8 + + +def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]: + """Encode ``payload`` into BBQR frames (``B$…``).""" + if len(type_code) != 1 or not type_code.isalnum(): + raise AnimatedQrError("bad BBQR type code") + encoding, encoded, split_mod = _bbqr_encode(payload, encoding) + chunk = budget_chars - 8 + if chunk < split_mod: + raise AnimatedQrError("QR budget too small for a BBQR frame") + chunk -= chunk % split_mod + if chunk < 1: + raise AnimatedQrError("QR budget too small for a BBQR frame") + if len(payload) > _MAX_MESSAGE_BYTES: + raise AnimatedQrError("BBQR payload exceeds the size cap") + total = -(-len(encoded) // chunk) + if total > 1295: + raise AnimatedQrError("BBQR transfer demands too many parts") + header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total) + frames: List[str] = [] + pos = 0 + for index in range(total): + frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk]) + pos += chunk + return frames + + +def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]: + """Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``.""" + frame_text = frame_text.strip() + if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX): + raise AnimatedQrError("not a BBQR frame") + encoding = frame_text[2] + type_code = frame_text[3] + if encoding not in ("H", "2", "Z"): + raise AnimatedQrError("unknown BBQR encoding") + try: + total = int(frame_text[4:6], 36) + index = int(frame_text[6:8], 36) + except ValueError: + raise AnimatedQrError("bad BBQR part numbers") from None + if total < 1 or not 0 <= index < total: + raise AnimatedQrError("bad BBQR part numbers") + if index >= _MAX_SESSION_PARTS: + raise AnimatedQrError("BBQR part number out of range") + return encoding, type_code, total, index, frame_text[8:] + + +def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes: + pieces: List[bytes] = [] + for part in encoded_parts: + if encoding == "H": + try: + pieces.append(bytes.fromhex(part)) + except ValueError: + raise AnimatedQrError("invalid BBQR hex payload") from None + continue + padding = (8 - (len(part) % 8)) % 8 + try: + pieces.append(base64.b32decode(part + "=" * padding)) + except (ValueError, TypeError): + raise AnimatedQrError("invalid BBQR base32 payload") from None + raw = b"".join(pieces) + if encoding == "Z": + try: + inflater = zlib.decompressobj(wbits=-10) + out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1) + except zlib.error: + raise AnimatedQrError("invalid BBQR zlib payload") from None + if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail: + raise AnimatedQrError("BBQR payload exceeds the size cap") + return out + return raw + + +# --------------------------------------------------------------------------- # +# Format detection & per-frame identity for the shared debounce +# --------------------------------------------------------------------------- # + +FORMAT_LABELS = { + "balqr": "BAL QR", + "ur1": "BC-UR v1", + "ur2": "BC-UR v2", + "bbqr": "BBQR", +} + + +def format_name(fmt: str) -> str: + """Human-readable name of a wire format for UI labels.""" + return FORMAT_LABELS.get(fmt, fmt) + + +def detect_format(text: str) -> Optional[str]: + """Return the wire format of a scanned string, or ``None``.""" + text = text.strip() + if not text: + return None + lowered = text.lower() + if lowered.startswith("balqr"): + return "balqr" + if text.startswith(_BBQR_PREFIX): + return "bbqr" + if not lowered.startswith("ur:"): + return None + if lowered.startswith("ur:bytes/"): + remainder = lowered[len("ur:bytes/") :] + first = remainder.split("/", 1)[0] + if "of" in first: + return "ur1" + if "-" in first: + return "ur2" + # Single-part: the whole remainder is the body. Prefer a bytewords v2 + # body (CBOR byte-string head 0x40..0x60), then BC32 v1. + try: + body = bytewords_minimal_decode(remainder) + except AnimatedQrError: + pass + else: + if body and 0x40 <= body[0] <= 0x60: + return "ur2" + try: + bc32_decode(remainder) + except AnimatedQrError: + return None + return "ur1" + return None + + +def parse_for_detection(text: str) -> Tuple[str, str, int, int]: + """Parse a frame and return ``(format, session_key, frame_total, index)``. + + ``session_key`` identifies the transfer the frame belongs to and drives + the shared reset/ignore/accept debounce. Raises + :class:`AnimatedQrError` when the text cannot be parsed. + """ + fmt = detect_format(text) + if fmt == "balqr": + total, index, _compressed, _payload = _parse_balqr(text) + return "balqr", "balqr:{}".format(total), total, index + if fmt == "ur1": + index, total, digest, _frag = ur1_parse_part(text) + return "ur1", "ur1:{}".format(digest), total, index + if fmt == "ur2": + seq, seq_len, message_len, checksum, _data = ur2_parse_part(text) + return "ur2", "ur2:{}-{}-{}".format(seq_len, message_len, checksum), seq_len, seq + if fmt == "bbqr": + encoding, type_code, total, index, _payload = bbqr_parse_part(text) + return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index + raise FormatNotDetectedError("Not a supported QR transfer format") + + +def _parse_balqr(text: str) -> Tuple[int, int, bool, str]: + from bal.core.qrtransfer import parse_frame + + return parse_frame(text) + + +# --------------------------------------------------------------------------- # +# Receive sessions (order-independent assembly per format) +# --------------------------------------------------------------------------- # + +class _BalQrSession: + def __init__(self): + self._frames: Dict[int, str] = {} + self._total = 0 + self._compressed = False + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._frames) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._frames) >= self._total + + def add(self, text: str) -> str: + total, index, compressed, payload = _parse_balqr(text) + if self._total and total != self._total: + raise TransferConflictError("BAL QR transfer total changed") + if len(self._frames) >= _MAX_SESSION_PARTS: + raise SessionLimitError("too many BAL QR frames") + if not self._total: + self._total = total + self._compressed = compressed + if index in self._frames: + return "dup" + self._frames[index] = payload + return "ok" + + def resolve(self) -> Tuple[str, bool]: + from bal.core.qrtransfer import assemble + + text = assemble(self._frames, self._total) + return text, self._compressed + + +class _Ur1Session: + def __init__(self): + self._total = 0 + self._digest = "" + self._fragments: Dict[int, str] = {} + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._fragments) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._fragments) >= self._total + + def add(self, text: str) -> str: + index, total, digest, fragment = ur1_parse_part(text) + if self._total: + if total != self._total or digest != self._digest: + raise TransferConflictError("BC-UR v1 transfer digest changed") + else: + self._total = total + self._digest = digest + if total > _MAX_SESSION_PARTS: + raise SessionLimitError("BC-UR v1 demands too many parts") + if index in self._fragments: + return "dup" + self._fragments[index] = fragment + return "ok" + + def resolve(self) -> Tuple[str, bool]: + full = "".join(self._fragments[i] for i in range(1, self._total + 1)) + try: + message = bc32_decode(full) + except AnimatedQrError: + raise ChecksumError("BC-UR v1 checksum mismatch") from None + if self._digest and _ur1_digest(message) != self._digest: + raise ChecksumError("BC-UR v1 digest mismatch") + return _transfer_text(unwrap_ur_cbor(message)), False + + +class _Ur2Session: + """Fountain decoder mirroring the reference (C++/python) semantics.""" + + def __init__(self): + self._seq_len = 0 + self._message_len = 0 + self._checksum = 0 + self._fragment_len = 0 + self._received: Set[int] = set() + self._simple: Dict[FrozenSet[int], bytes] = {} + self._mixed: Dict[FrozenSet[int], bytes] = {} + self._queue: List[Tuple[FrozenSet[int], bytes]] = [] + self._processed = 0 + self._result: Optional[bytes] = None + self._bad = False + + @property + def total(self) -> int: + return self._seq_len + + @property + def received(self) -> int: + return self._processed + + @property + def done(self) -> bool: + return self._result is not None + + def add(self, text: str) -> str: + seq, seq_len, message_len, checksum, data = ur2_parse_part(text) + if self._seq_len: + if not self._validate(seq_len, message_len, checksum, len(data)): + raise TransferConflictError("BC-UR v2 transfer header changed") + else: + self._seq_len = seq_len + self._message_len = message_len + self._checksum = checksum + self._fragment_len = len(data) + if seq_len > _MAX_SESSION_PARTS or message_len > _MAX_MESSAGE_BYTES: + raise SessionLimitError("BC-UR v2 session exceeds safety caps") + indexes = frozenset(choose_fragments(seq, self._seq_len, self._checksum)) + self._receive(indexes, bytes(data)) + return "ok" + + def _validate(self, seq_len: int, message_len: int, checksum: int, data_len: int) -> bool: + return ( + seq_len == self._seq_len + and message_len == self._message_len + and checksum == self._checksum + and data_len == self._fragment_len + ) + + def _receive(self, indexes: FrozenSet[int], data: bytes) -> None: + if self._result is not None or self._bad: + return + self._queue.append((indexes, data)) + while self._result is None and not self._bad and self._queue: + self._process(self._queue.pop(0)) + self._processed += 1 + + def _process(self, item: Tuple[FrozenSet[int], bytes]) -> None: + indexes, data = item + if len(indexes) == 1: + self._process_simple(indexes, data) + else: + self._process_mixed(indexes, data) + + def _process_simple(self, indexes: FrozenSet[int], data: bytes) -> None: + fragment_index = next(iter(indexes)) + if fragment_index in self._received: + return + self._simple[indexes] = data + self._received.add(fragment_index) + if self._received == set(range(self._seq_len)): + self._finish() + return + self._reduce_mixed_by(indexes, data) + + def _reduce_mixed_by(self, indexes: FrozenSet[int], data: bytes) -> None: + new_mixed: Dict[FrozenSet[int], bytes] = {} + for other_indexes, other_data in self._mixed.items(): + reduced = self._reduce_part(other_indexes, other_data, indexes, data) + if len(reduced[0]) == 1: + self._queue.append(reduced) + else: + new_mixed[reduced[0]] = reduced[1] + self._mixed = new_mixed + + def _process_mixed(self, indexes: FrozenSet[int], data: bytes) -> None: + if indexes in self._mixed: + return + reduced_indexes, reduced_data = indexes, data + for simple_indexes, simple_data in self._simple.items(): + reduced_indexes, reduced_data = self._reduce_part( + reduced_indexes, reduced_data, simple_indexes, simple_data + ) + for other_indexes, other_data in list(self._mixed.items()): + reduced_indexes, reduced_data = self._reduce_part( + reduced_indexes, reduced_data, other_indexes, other_data + ) + if len(reduced_indexes) == 1: + self._queue.append((reduced_indexes, reduced_data)) + else: + self._reduce_mixed_by(reduced_indexes, reduced_data) + if reduced_indexes not in self._mixed: + self._mixed[reduced_indexes] = reduced_data + + @staticmethod + def _reduce_part( + a_indexes: FrozenSet[int], a_data: bytes, b_indexes: FrozenSet[int], b_data: bytes + ) -> Tuple[FrozenSet[int], bytes]: + if b_indexes == a_indexes or not b_indexes.issubset(a_indexes): + return a_indexes, a_data + new_indexes = a_indexes - b_indexes + new_data = bytes(x ^ y for x, y in zip(a_data, b_data, strict=True)) + return new_indexes, new_data + + def _finish(self) -> None: + fragments = [] + for index in range(self._seq_len): + key = frozenset([index]) + if key not in self._simple: + self._bad = True + return + fragments.append(self._simple[key]) + message = b"".join(fragments)[: self._message_len] + if crc32_int(message) != self._checksum: + self._bad = True + return + self._result = message + + def resolve(self) -> Tuple[str, bool]: + if self._bad: + raise ChecksumError("BC-UR v2 message checksum mismatch") + if self._result is None: + raise AnimatedQrError("BC-UR v2 session is not complete") + return _transfer_text(unwrap_ur_cbor(self._result)), False + + +class _BbqrSession: + def __init__(self): + self._total = 0 + self._encoding = "" + self._type_code = "" + self._parts: Dict[int, str] = {} + + @property + def total(self) -> int: + return self._total + + @property + def received(self) -> int: + return len(self._parts) + + @property + def done(self) -> bool: + return bool(self._total) and len(self._parts) >= self._total + + def add(self, text: str) -> str: + encoding, type_code, total, index, payload = bbqr_parse_part(text) + if self._total: + if (encoding, type_code, total) != (self._encoding, self._type_code, self._total): + raise TransferConflictError("BBQR frame header changed") + else: + self._total = total + self._encoding = encoding + self._type_code = type_code + if index in self._parts: + return "dup" + self._parts[index] = payload + return "ok" + + def resolve(self) -> Tuple[str, bool]: + ordered = [self._parts[i] for i in range(self._total)] + raw = _bbqr_decode(ordered, self._encoding) + return _transfer_text(raw), False + + +def _transfer_text(raw: bytes) -> str: + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + raise AnimatedQrError("decoded transfer is not valid UTF-8") from None + + +class AnimatedQrSession: + """Facade over the per-format receive sessions used by the QR import page.""" + + _DIALECTS = (("balqr", "_BalQrSession"), ("ur1", "_Ur1Session"), ("ur2", "_Ur2Session"), ("bbqr", "_BbqrSession")) + + def __init__(self): + self._inner: Optional[object] = None + self._fmt: Optional[str] = None + + @property + def format(self) -> Optional[str]: + return self._fmt + + def add_part(self, text: str) -> str: + """Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input.""" + fmt = detect_format(text) + if fmt is None: + raise FormatNotDetectedError("Not a supported QR transfer format") + if self._inner is None: + self._fmt = fmt + self._inner = self._make(fmt) + elif fmt != self._fmt: + raise TransferConflictError( + "Switched QR format mid-import ({} -> {})".format(self.format, fmt) + ) + return self._inner.add(text) # type: ignore[no-any-return] + + @staticmethod + def _make(fmt: str) -> object: + if fmt == "balqr": + return _BalQrSession() + if fmt == "ur1": + return _Ur1Session() + if fmt == "ur2": + return _Ur2Session() + if fmt == "bbqr": + return _BbqrSession() + raise AssertionError("unknown animated-QR format {}".format(fmt)) + + @property + def total(self) -> int: + return self._inner.total if self._inner is not None else 0 + + @property + def received(self) -> int: + return self._inner.received if self._inner is not None else 0 + + @property + def done(self) -> bool: + return bool(self._inner is not None and self._inner.done) + + def resolve(self) -> Tuple[str, bool]: + if self._inner is None: + raise AnimatedQrError("no transfer has been received") + return self._inner.resolve() # type: ignore[no-any-return] diff --git a/bal/core/heirs.py b/bal/core/heirs.py index e2d4dad..a1c1f3a 100644 --- a/bal/core/heirs.py +++ b/bal/core/heirs.py @@ -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): diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index c0fbe55..75cdc52 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -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).""" diff --git a/bal/core/qrtransfer.py b/bal/core/qrtransfer.py index e131203..227e00a 100644 --- a/bal/core/qrtransfer.py +++ b/bal/core/qrtransfer.py @@ -209,4 +209,4 @@ def __compute_total(transfer_len, chunk_size, flags): ) if transfer_len <= budget * total: return total - total += 1 \ No newline at end of file + total += 1 diff --git a/bal/core/util.py b/bal/core/util.py index dbcf1f8..65cfa03 100644 --- a/bal/core/util.py +++ b/bal/core/util.py @@ -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).""" diff --git a/bal/core/will.py b/bal/core/will.py index bdd7cfc..1dcb137 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -26,7 +26,6 @@ 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.i18n import _ @@ -45,7 +44,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 +142,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 +164,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 +464,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: @@ -1327,49 +1326,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") 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 = 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 +1409,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: diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py index e9e1e1b..4fc83de 100644 --- a/bal/gui/qt/calendar.py +++ b/bal/gui/qt/calendar.py @@ -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 diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 4395a3b..b4e9896 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -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 @@ -82,6 +81,7 @@ from PyQt6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, QApplication, + QButtonGroup, QCheckBox, QComboBox, QDateTimeEdit, @@ -94,6 +94,7 @@ from PyQt6.QtWidgets import ( QMenu, QMenuBar, QPushButton, + QRadioButton, QScrollArea, QSizePolicy, QSpinBox, @@ -121,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, diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 4e46e82..caafbea 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -11,9 +11,14 @@ 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. + * WillExportDialog - unified export window (File / QR / Audio); + embeds a BalQrExportWidget for the QR transport. + * BalQrExportWidget - render+autoplay the will as QR frames. + * WillImportDialog - unified import window (File / QR / Audio); + embeds a BalQrImportWidget for the QR transport. + * BalQrImportWidget - capture/assemble a will from QR frames via + a continuous, hands-free camera loop (change/detection debounce via + :func:`qr_import_accept_frame`) 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). @@ -23,27 +28,39 @@ use them (see ``lists`` imports below). """ import io +import json +import re import zlib - from typing import TYPE_CHECKING +from electrum.util import MyEncoder + +from ...core.animated_qr import ( + AnimatedQrError, + AnimatedQrSession, + SessionLimitError, + TransferConflictError, + bbqr_frames, + format_name, + parse_for_detection, + ur1_frames, + ur2_frames, +) 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 ...core.reminders import build_ics_reminders from .calendar import BalCalendarButton from .common import ( - _, - _logger, + HEIR_DUST_AMOUNT, + HEIR_REAL_AMOUNT, AmountException, Any, BalTimestamp, @@ -52,16 +69,15 @@ from .common import ( Buttons, Callable, CancelButton, - HEIR_DUST_AMOUNT, - HEIR_REAL_AMOUNT, HeirAmountIsDustException, HeirChangeException, HeirNotFoundException, MessageBoxMixin, Network, NoHeirsException, - NoWillExecutorNotPresent, NotCompleteWillException, + NoWillExecutorNotPresent, + QButtonGroup, QCheckBox, QComboBox, QDialog, @@ -70,27 +86,30 @@ from .common import ( QLabel, QLineEdit, QPushButton, + QRadioButton, QScrollArea, QSizePolicy, QSpinBox, QStackedWidget, + Qt, QTimer, QVBoxLayout, QWidget, - Qt, TaskThread, TxBroadcastError, TxFeesChangedException, Util, WaitingDialog, Will, + WillexecutorChangeException, WillExecutorFeeTooHighException, WillExecutorNotPresent, + Willexecutors, WillExpiredException, WillItem, WillPostponedException, - WillexecutorChangeException, - Willexecutors, + _, + _logger, bring_to_front, decimal_point_to_base_unit_name, draw_qr, @@ -99,13 +118,14 @@ from .common import ( log_error, partial, pyqtSignal, - read_QIcon_from_bytes, read_json_file, + read_QIcon_from_bytes, show_modal, show_on_top, stop_thread, time, top_level_of, + tx_from_any, write_json_file, ) from .widgets import ( @@ -120,6 +140,29 @@ if TYPE_CHECKING: # imported lazily where needed to avoid a dialogs<->lists import cycle. +# Animated-QR formats beyond the legacy BAL QR. Combined with the format +# selector in :class:`BalQrExportWidget` they give the export page +# interoperable BC-UR v1/v2 and BBQR output while keeping BAL QR as the +# default (and the only format understood by older plugin versions). +ANIMATED_QR_FORMATS = ("ur1", "ur2", "bbqr") + + +def encode_animated_frames(transfer, fmt, budget_chars): + """Encode the transfer text into the given animated-QR format. + + ``transfer`` is the BAL QR transfer text (str). ``budget_chars`` is the + maximum length of one frame (the exporter's "QR code size" preset). + """ + payload = transfer.encode("utf-8") + if fmt == "ur1": + return ur1_frames(payload, budget_chars) + if fmt == "ur2": + return ur2_frames(payload, budget_chars) + if fmt == "bbqr": + return bbqr_frames(payload, budget_chars, encoding="Z") + raise QrTransferError("unknown animated QR format: {}".format(fmt)) + + class BalDialog(QDialog,MessageBoxMixin): _stopping = False def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): @@ -2574,6 +2617,73 @@ class HeirsDialog(BalDialog, MessageBoxMixin): # QR / audio will transfer # --------------------------------------------------------------------------- # +def export_filter_options(): + """The export filters shared by the File / QR / Audio export dialogs. + + Mirrors the historical All / Valid / Valid-NC choices of the "Export" + file menu: All selects every will item, Valid only the valid ones and + Valid NC the valid ones that are NOT yet fully signed (Complete). + """ + return [ + (_("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"), + ), + ] + + +def filter_willitems(source, filters, filter_index): + """The will items of ``source`` matched by ``filters[filter_index]``.""" + _label, fn = filters[filter_index] + return {wid: wi for wid, wi in source.items() if fn(wi)} + + +def serialize_tx_list(willitems): + """The serialized transaction strings of the given will items, sorted by txid.""" + items = sorted(willitems.values(), key=lambda wi: str(wi.tx.txid())) + return [str(wi.tx) for wi in items] + + +def decode_will_payload(text) -> tuple[Any, Any]: + """Autodetect: whole-will JSON or transaction list? + + Returns ``("will", dict_of_willitems_data)`` when ``text`` is a JSON + object whose values are dicts containing a ``"tx"`` key (the whole-will + format produced by :meth:`BalWindow.export_json_file` and friends). + Otherwise returns ``("txs", [tx_strings])`` where the transaction + strings were split on commas and/or newlines. + """ + text = text.strip() + try: + data = json.loads(text) + except (json.JSONDecodeError, ValueError): + data = None + if isinstance(data, dict) and data: + if all(isinstance(v, dict) and "tx" in v for v in data.values()): + return ("will", data) + parts = [p for p in re.split(r"[,\r\n]+", text) if p.strip()] + return ("txs", parts) + + +def audio_bitrates(): + """The transfer speeds (KB/sec) offered by the ``audio_modem`` plugin.""" + try: + import amodem.config + except Exception: + return [] + return sorted(amodem.config.bitrates.keys()) + + +def current_kbps(plugin): + """The KB/sec the given plugin is configured with (1 when unknown).""" + try: + return int(round(plugin.modem_config.modem_bps / 1e3)) + except Exception: + return 1 + + class BalQrImage(QWidget): """A widget that renders one QR code, scaled to its own size. @@ -2612,65 +2722,33 @@ class BalQrImage(QWidget): QWidget.paintEvent(self, event) -class WillQrExportDialog(BalDialog): - """Export a will as a sequence of QR codes, one per screen. +class BalQrExportWidget(QWidget): + """Self-contained QR export page: view, navigation, autoplay, resolution. - 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). + Renders the will transfer one QR code at a time. The user walks the + frames with the Prev/Next arrows, can auto-advance at a chosen speed + (with optional looping) and can change the "QR code size" preset live, + which is the resolution: how many payload bytes each frame carries. + The page (re)displays itself on :meth:`set_tx_strings`. """ - 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 + def __init__(self, chunk_size=CHUNK_PRESETS[0][1], parent=None): + QWidget.__init__(self, parent) + self.chunk_size = chunk_size + self.format = "balqr" + self.tx_strings = [] + self.transfer = "" + self.frames = [] + self.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() @@ -2685,14 +2763,6 @@ class WillQrExportDialog(BalDialog): 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() @@ -2710,8 +2780,10 @@ class WillQrExportDialog(BalDialog): 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.") + _( + "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) @@ -2729,27 +2801,55 @@ class WillQrExportDialog(BalDialog): 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) + format_row = QHBoxLayout() + format_row.addWidget(QLabel(_("Format:"))) + self.format_combo = QComboBox() + self._format_options = ["balqr"] + list(ANIMATED_QR_FORMATS) + self.format_combo.addItems( + [ + format_name(fmt) + ((" (default)") if fmt == "balqr" else "") + for fmt in self._format_options + ] + ) + self.format_combo.setCurrentIndex(0) + self.format_combo.currentIndexChanged.connect(self._on_format_change) + format_row.addWidget(self.format_combo) + self.format_hint = QLabel() + self.format_hint.setWordWrap(True) + format_row.addWidget(self.format_hint, 1) + vbox.addLayout(format_row) - 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) + def set_tx_strings(self, tx_strings): + """Rebuild the transfer frames from the given serialized transactions.""" + self.tx_strings = list(tx_strings) + self._stop_auto() self.transfer = encode_transfer(self.tx_strings, compress=False) self._refresh_frames() - self.total_frames = len(self.frames) + self._update_intro() + self._render() + + @property + def total_frames(self): + return len(self.frames) def _refresh_frames(self): - self.frames = split_frames( - self.transfer, self.chunk_size, compressed=False - ) + if self.format == "balqr": + self.frames = split_frames( + self.transfer, self.chunk_size, compressed=False + ) + else: + self.frames = encode_animated_frames( + self.transfer, self.format, self.chunk_size + ) self.index = 0 + def _on_format_change(self, index): + self._stop_auto() + self.format = self._format_options[index] + self._refresh_frames() + self._render() + self._update_intro() + def _on_chunk_change(self, index): self._stop_auto() self.chunk_size = CHUNK_PRESETS[index][1] @@ -2785,34 +2885,33 @@ class WillQrExportDialog(BalDialog): 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) + self.format_hint.setText( + { + "balqr": _( + "Proprietary format; every other BAL wallet understands it." + ), + "ur1": _("Legacy BC-UR v1 (ur:bytes), compatible with older " + "Blockchain Commons tools."), + "ur2": _("Standard BC-UR v2 fountain codes (ur:bytes)."), + "bbqr": _("Coinkite BBQR (B$ frames) for BitKit & friends."), + }[self.format] ) - - 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() + if self.format == "balqr": + 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) + ) + else: + self.intro_label.setText( + _( + "Scan the QR codes below with the will-opening device.\n" + "{} codes carry the whole transfer (any order works, " + "duplicates are ignored)." + ).format(self.total_frames) + ) def _prev(self): if self.index > 0: @@ -2825,6 +2924,8 @@ class WillQrExportDialog(BalDialog): self._render() def _render(self): + if not self.frames: + return self.qr_view.set_text(self.frames[self.index]) self.progress_label.setText( _("Frame {} of {}").format(self.index + 1, len(self.frames)) @@ -2832,43 +2933,674 @@ class WillQrExportDialog(BalDialog): self.prev_btn.setEnabled(self.index > 0) self.next_btn.setEnabled(self.index < len(self.frames) - 1) - def _audio_send(self): + +class WillExportDialog(BalDialog): + """One window to export a will to a file, QR codes or audio. + + The export filter (All / Valid / Valid NC) sits at the top and applies to + every option. Below it the user picks one of the three transports; each + option shows its transport-specific settings: the file content mode + (whole will item vs only the transactions), the QR resolution (frame + size) and autoplay speed, and the audio KB/sec. If the ``audio_modem`` + plugin is missing only the audio option is disabled - file and QR stay + usable. + """ + + MODE_FILE = 0 + MODE_QR = 1 + MODE_AUDIO = 2 + + def __init__(self, bal_window, will=None, bal_plugin=None, initial_mode="file"): + BalDialog.__init__(self, bal_window.window, bal_plugin, _("Export will")) + self.bal_window = bal_window + self._source = will if will is not None else bal_window.willitems + self._filters = export_filter_options() + self._filter_index = 0 try: - self.bal_window._audio_send_payload(self.audio_payload) + chunk = int(bal_plugin.QR_CHUNK_SIZE.get()) + except Exception: + chunk = CHUNK_PRESETS[0][1] + if not self._selected_items(): + self.show_message(_("No will transaction to export.")) + self.close() + return + + vbox = QVBoxLayout(self) + + 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) + # Content scope: whole will items vs only the serialized transactions. + # Applies uniformly to every transport (File / QR / Audio). + self.content_check = QCheckBox(_("Whole will")) + self.content_check.setChecked(True) + self.content_check.setToolTip( + _( + "Export the full will items (all details/statuses) as JSON, " + "or only the serialized transactions." + ) + ) + self.content_check.toggled.connect(self._on_content_toggle) + filter_row.addWidget(self.content_check) + filter_row.addStretch(1) + vbox.addLayout(filter_row) + + mode_row = QHBoxLayout() + mode_row.addWidget(QLabel(_("Send as:"))) + self.transport_group = QButtonGroup(self) + self.transport_file = QRadioButton(_("File")) + self.transport_qr = QRadioButton(_("QR Code")) + self.transport_audio = QRadioButton(_("Audio")) + self.transport_group.addButton(self.transport_file, self.MODE_FILE) + self.transport_group.addButton(self.transport_qr, self.MODE_QR) + self.transport_group.addButton(self.transport_audio, self.MODE_AUDIO) + for rb in (self.transport_file, self.transport_qr, self.transport_audio): + mode_row.addWidget(rb) + mode_row.addStretch(1) + vbox.addLayout(mode_row) + self.transport_group.idClicked.connect(self._on_mode_clicked) + + self.stacked = QStackedWidget() + self.file_page = self._build_file_page() + self.stacked.addWidget(self.file_page) + self.qr_page = BalQrExportWidget(chunk_size=chunk) + self.stacked.addWidget(self.qr_page) + self.audio_page = self._build_audio_page() + self.stacked.addWidget(self.audio_page) + vbox.addWidget(self.stacked) + + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + + mode_ids = { + "file": self.MODE_FILE, + "qr": self.MODE_QR, + "audio": self.MODE_AUDIO, + } + mode = mode_ids.get(initial_mode, self.MODE_FILE) + (self.transport_file, self.transport_qr, self.transport_audio)[ + mode + ].setChecked(True) + self.qr_page.set_tx_strings(self._payload_strings()) + self._on_mode_clicked(mode) + self._update_info() + + def _build_file_page(self): + page = QWidget() + v = QVBoxLayout(page) + v.addWidget( + QLabel( + _( + "Export the will as a JSON file. Uncheck \"Whole will\" " + "above to export only the serialized transactions " + "(comma-separated)." + ) + ) + ) + self.file_info_label = QLabel() + v.addWidget(self.file_info_label) + self.file_export_btn = QPushButton(_("Export…")) + self.file_export_btn.clicked.connect(self._export_file) + v.addWidget(self.file_export_btn, alignment=Qt.AlignmentFlag.AlignRight) + return page + + def _build_audio_page(self): + page = QWidget() + v = QVBoxLayout(page) + self.audio_warn_label = QLabel() + self.audio_warn_label.setWordWrap(True) + self._audio_plugin = self.bal_window.get_audio_modem_plugin() + self.bitrates = audio_bitrates() + available = self._audio_plugin is not None + if available: + self.audio_warn_label.hide() + else: + self.audio_warn_label.setText( + _("Audio MODEM plugin is not available.") + ) + v.addWidget(self.audio_warn_label) + kbps_row = QHBoxLayout() + kbps_row.addWidget(QLabel(_("Speed (KB/sec):"))) + self.kbps_combo = QComboBox() + self.kbps_combo.addItems([str(x) for x in self.bitrates]) + current = current_kbps(self._audio_plugin) + if self.bitrates and current in self.bitrates: + self.kbps_combo.setCurrentIndex(self.bitrates.index(current)) + kbps_row.addWidget(self.kbps_combo) + kbps_row.addStretch(1) + v.addLayout(kbps_row) + self.audio_info_label = QLabel() + v.addWidget(self.audio_info_label) + self.audio_send_btn = QPushButton(_("Send")) + self.audio_send_btn.setEnabled(available) + self.audio_send_btn.clicked.connect(self._send_audio) + v.addWidget(self.audio_send_btn, alignment=Qt.AlignmentFlag.AlignRight) + return page + + def _selected_items(self): + return filter_willitems(self._source, self._filters, self._filter_index) + + def _payload_strings(self): + """The data handed to the QR page, honouring the content scope. + + ``[""]`` when "Whole will" is checked, otherwise the serialized + transaction strings (the QR transfer wraps them independently). + """ + items = self._selected_items() + if self.content_check.isChecked(): + return [self._whole_will_json()] + return serialize_tx_list(items) + + def _payload_text(self): + """The raw audio payload, honouring the content scope.""" + if self.content_check.isChecked(): + return self._whole_will_json() + return "\n".join(serialize_tx_list(self._selected_items())) + + def _whole_will_json(self): + # Use Electrum's MyEncoder so the ``tx`` Transaction object (and any + # datetime fields) are serialized the same way write_json_file does, + # otherwise json.dumps raises "Object of type Transaction is not JSON + # serializable". + return json.dumps( + {wid: wi.to_dict() for wid, wi in self._selected_items().items()}, + cls=MyEncoder, + ) + + def _on_filter_change(self, index): + previous = self._filter_index + self._filter_index = index + if not self._selected_items(): + # 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 + if self.transport_qr.isChecked(): + self.qr_page.set_tx_strings(self._payload_strings()) + self._update_info() + + def _on_content_toggle(self, checked): + if self.transport_qr.isChecked(): + self.qr_page.set_tx_strings(self._payload_strings()) + self._update_info() + + def _on_mode_clicked(self, mode): + if mode == self.MODE_QR: + self.qr_page.set_tx_strings(self._payload_strings()) + self.stacked.setCurrentWidget(self.qr_page) + elif mode == self.MODE_AUDIO: + self.stacked.setCurrentWidget(self.audio_page) + else: + self.stacked.setCurrentWidget(self.file_page) + self._update_info() + + def _update_info(self): + count = len(self._selected_items()) + noun = _("item(s)") if self.content_check.isChecked() else _("transaction(s)") + self.file_info_label.setText( + _("{} {} will be exported.").format(count, noun) + ) + self.audio_info_label.setText( + _("{} {} will be sent.").format(count, noun) + ) + + def _export_file(self): + items = self._selected_items() + if not items: + self.show_message(_("No will transaction matches the selected filter.")) + return + if self.content_check.isChecked(): + exporter = partial(self.bal_window.export_json_file, will=items) + title = "will" + else: + exporter = partial(self.bal_window.export_tx_file, will=items) + title = "will_tx" + try: + export_meta_gui(self.bal_window.window, title, exporter) + except Exception as e: + self.show_error(str(e)) + raise e + + def _send_audio(self): + try: + kbps = int(self.kbps_combo.currentText()) + except ValueError: + self.show_error(_("Invalid audio speed.")) + return + if not self.bitrates or kbps not in self.bitrates: + self.show_error(_("Invalid audio speed.")) + return + items = self._selected_items() + if not items: + self.show_message(_("No will transaction matches the selected filter.")) + return + try: + self.bal_window.set_audio_modem_bitrate(kbps) + except Exception as e: + self.show_error(str(e)) + return + payload = self._payload_text() + try: + self.bal_window._audio_send_payload(payload) except Exception as e: log_error(e, self) self.show_error(str(e)) + return + self.close() -class WillQrImportDialog(BalDialog): - """Import a will by scanning its QR codes (or receiving it by audio). +class WillImportDialog(BalDialog): + """One window to import a will from a file, QR codes or audio. + + Mirrors :class:`WillExportDialog`: the user picks one of the three + transports. File imports are shown in a read-only + :class:`WillDetailDialog`; QR and audio captures go through the shared + review+sign wizard (:class:`WillTxReviewSignDialog`). The live will is + never touched by any of the three flows. + """ + + MODE_FILE = 0 + MODE_QR = 1 + MODE_AUDIO = 2 + + def __init__(self, bal_window, bal_plugin=None): + BalDialog.__init__(self, bal_window.window, bal_plugin, _("Import will")) + self.bal_window = bal_window + self.bal_plugin = bal_plugin + + vbox = QVBoxLayout(self) + intro = QLabel( + _( + "Choose how the will was exported: from a file, QR codes or " + "audio.\nThe import never touches the live will." + ) + ) + intro.setWordWrap(True) + vbox.addWidget(intro) + + mode_row = QHBoxLayout() + mode_row.addWidget(QLabel(_("Import from:"))) + self.transport_group = QButtonGroup(self) + self.transport_file = QRadioButton(_("File")) + self.transport_qr = QRadioButton(_("QR Code")) + self.transport_audio = QRadioButton(_("Audio")) + self.transport_group.addButton(self.transport_file, self.MODE_FILE) + self.transport_group.addButton(self.transport_qr, self.MODE_QR) + self.transport_group.addButton(self.transport_audio, self.MODE_AUDIO) + for rb in (self.transport_file, self.transport_qr, self.transport_audio): + mode_row.addWidget(rb) + mode_row.addStretch(1) + vbox.addLayout(mode_row) + self.transport_group.idClicked.connect(self._on_mode_clicked) + + self.stacked = QStackedWidget() + self.file_page = self._build_file_page() + self.stacked.addWidget(self.file_page) + self.qr_page = BalQrImportWidget( + bal_window, bal_plugin, close_cb=self.close + ) + self.stacked.addWidget(self.qr_page) + self.audio_page = self._build_audio_page() + self.stacked.addWidget(self.audio_page) + vbox.addWidget(self.stacked) + + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + + self.transport_file.setChecked(True) + self._on_mode_clicked(self.MODE_FILE) + + def _build_file_page(self): + page = QWidget() + v = QVBoxLayout(page) + lbl = QLabel( + _( + "Import the JSON will file written by the Export ▶ File " + "option. The will opens in a read-only preview window." + ) + ) + lbl.setWordWrap(True) + v.addWidget(lbl) + btn = QPushButton(_("Choose will file…")) + btn.clicked.connect(self._import_file) + v.addWidget(btn, alignment=Qt.AlignmentFlag.AlignLeft) + return page + + def _build_audio_page(self): + page = QWidget() + v = QVBoxLayout(page) + self.audio_warn_label = QLabel() + self.audio_warn_label.setWordWrap(True) + self._audio_plugin = self.bal_window.get_audio_modem_plugin() + self.bitrates = audio_bitrates() + available = self._audio_plugin is not None + if available: + self.audio_warn_label.hide() + else: + self.audio_warn_label.setText( + _("Audio MODEM plugin is not available.") + ) + v.addWidget(self.audio_warn_label) + kbps_row = QHBoxLayout() + kbps_row.addWidget(QLabel(_("Speed (KB/sec):"))) + self.kbps_combo = QComboBox() + self.kbps_combo.addItems([str(x) for x in self.bitrates]) + current = current_kbps(self._audio_plugin) + if self.bitrates and current in self.bitrates: + self.kbps_combo.setCurrentIndex(self.bitrates.index(current)) + kbps_row.addWidget(self.kbps_combo) + kbps_row.addStretch(1) + v.addLayout(kbps_row) + self.status_label = QLabel(_("Waiting for the audio transfer…")) + v.addWidget(self.status_label) + btns = QHBoxLayout() + self.receive_btn = QPushButton(_("Receive by audio…")) + self.receive_btn.setEnabled(available) + self.receive_btn.clicked.connect(self._audio_receive) + btns.addWidget(self.receive_btn) + btns.addStretch(1) + v.addLayout(btns) + return page + + def _on_mode_clicked(self, mode): + if mode == self.MODE_QR: + self.stacked.setCurrentWidget(self.qr_page) + elif mode == self.MODE_AUDIO: + self.stacked.setCurrentWidget(self.audio_page) + else: + self.stacked.setCurrentWidget(self.file_page) + + def _import_file(self): + self.bal_window.import_will_into_details() + + def _audio_receive(self): + """Start a receiver thread on the chosen speed and finish the import.""" + try: + kbps = int(self.kbps_combo.currentText()) + except ValueError: + self.show_error(_("Invalid audio speed.")) + return + if not self.bitrates or kbps not in self.bitrates: + self.show_error(_("Invalid audio speed.")) + return + try: + self.bal_window.set_audio_modem_bitrate(kbps) + except Exception as e: + self.show_error(str(e)) + return + + try: + import amodem # noqa: F401 # type: ignore (guaranteed by is_available) + except Exception as e: + self.show_error(str(e)) + return + plugin = self._audio_plugin + self.status_label.setText(_("Receiving…")) + self.receive_btn.setEnabled(False) + + 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): + self.receive_btn.setEnabled(True) + if not blob: + return + try: + text = zlib.decompress(blob).decode("ascii") + except Exception as e: + self.show_error(str(e)) + return + if not text.strip(): + self.show_error(_("No transaction data received.")) + return + self.close() + _complete_import( + self.bal_window, + self.bal_plugin, + text, + show_error=self.show_error, + show_warning=self.show_warning, + close=lambda: None, + ) + + def on_error(exec_info): + self.receive_btn.setEnabled(True) + log_error(exec_info, self) + + WaitingDialog( + self, + _("Waiting for audio ({:.1f} kbps)…").format( + plugin.modem_config.modem_bps / 1e3 + ), + receiver_thread, + on_success, + on_error, + ) + + +def _local_validity_pass(bal_window, 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 QR / audio import and the file-merge paths behave identically. + """ + date_to_check = getattr(bal_window, "date_to_check", None) + if date_to_check is None: + date_to_check = resolve_date_to_check( + bal_window.bal_plugin.is_basic_mode(), + bal_window.will_settings, + ) + history_label = bal_window.bal_plugin.HISTORY_LABEL.get() + try: + Will.add_willtree(items) + all_utxos = Util.get_available_utxos( + bal_window.wallet, + history_label, + Will.get_min_locktime(items, default_value=date_to_check), + ) + Will.check_invalidated( + items, Will.utxos_strs(all_utxos), bal_window.wallet + ) + Will.search_rai( + Will.get_all_inputs(items, only_valid=True), + all_utxos, + items, + bal_window.wallet, + ) + Will.check_signatures(items, bal_window.wallet) + except Exception as e: + log_error(e, bal_window) + + +def _complete_import(bal_window, bal_plugin, payload, *, show_error, show_warning, close): + """Shared tail of the QR / audio import flows. + + Autodetects the transferred ``payload`` with :func:`decode_will_payload`: + a whole-will (JSON of willitems) is shown read-only in a + :class:`WillDetailDialog`; a transaction list is parsed into local + :class:`WillItem` objects, run through the local validity pass and handed + to the review+sign wizard. The live will is never touched. + ``show_error`` / ``show_warning`` / ``close`` are callbacks supplied by + the per-transport dialog. + """ + kind, data = decode_will_payload(payload) + if kind == "will": + return _complete_import_will( + bal_window, bal_plugin, data, show_error=show_error, close=close + ) + return _complete_import_txs( + bal_window, bal_plugin, data, show_error=show_error, show_warning=show_warning, close=close + ) + + +def _complete_import_will(bal_window, bal_plugin, data, *, show_error, close): + """Build local WillItems from whole-will JSON data and open WillDetailDialog.""" + items = {} + for wid, d in data.items(): + try: + d = dict(d) + d["tx"] = tx_from_any(d["tx"]) + items[wid] = WillItem(d, _id=wid, wallet=bal_window.wallet) + except Exception as e: + show_error(_("Could not parse a transferred will item: {}").format(e)) + return + Will.normalize_will(items, bal_window.wallet) + for wi in items.values(): + wi.set_status("IMPORTED", True) + close() + from .dialogs import WillDetailDialog + + dlg = WillDetailDialog(bal_window, will=items) + show_on_top(dlg) + + +def _complete_import_txs(bal_window, bal_plugin, tx_strings, *, show_error, show_warning, close): + """Build local WillItems from serialized transactions and open the review wizard.""" + items = {} + for s in tx_strings: + try: + wi = WillItem({"tx": s}, wallet=bal_window.wallet) + except Exception as e: + show_error( + _("Could not parse a transferred transaction: {}").format(e) + ) + return + items[wi._id] = wi + Will.normalize_will(items, bal_window.wallet) + _local_validity_pass(bal_window, 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: + show_error( + _( + "The imported will contains no valid transaction in this " + "wallet." + ) + ) + close() + return + close() + skipped = len(items) - len(valid) + if skipped: + show_warning( + _( + "{} imported transaction(s) are not valid in this wallet " + "and were skipped." + ).format(skipped) + ) + wizard = WillTxReviewSignDialog( + bal_window, will=items, bal_plugin=bal_plugin + ) + if wizard.aborted: + return + show_on_top(wizard) + + +def qr_import_accept_frame( + state, fmt, session_key, frame_total, index, payload, stable_reads=2 +): + """Change/detection policy for hands-free sequential QR frame import. + + The continuous camera loop reads the same displayed code many times per + second, so we must decide when a scanned frame is worth storing: + + * frames whose ``(index, payload)`` identity differs from the last + accepted one only count as ``pending`` until the same identity has been + seen ``stable_reads`` times in a row -- this mirrors the export-side + slideshow pacing and swallows transition artifacts; + * re-reading the currently accepted frame is ``ignore``d; + * a frame whose ``session_key`` (transfer identity, e.g. ``"balqr:3"`` or + ``"ur2:2-31-3804692811"``) contradicts the transfer already being built + is a ``reset`` (a different transfer was presented). + + ``state`` is a mutable mapping with keys ``last_index``, ``last_payload``, + ``pending_index``, ``pending_payload``, ``pending_count`` and ``key``. + Returns one of ``"reset"``, ``"accept"``, ``"pending"``, ``"ignore"``. + """ + current_key = state.get("key") + if current_key and session_key != current_key: + return "reset" + if index == state.get("last_index") and payload == state.get("last_payload"): + return "ignore" + if index == state.get("pending_index") and payload == state.get("pending_payload"): + state["pending_count"] = state.get("pending_count", 0) + 1 + else: + state["pending_index"] = index + state["pending_payload"] = payload + state["pending_count"] = 1 + if state["pending_count"] >= stable_reads: + state["key"] = session_key + state["last_index"] = index + state["last_payload"] = payload + state["pending_index"] = None + state["pending_payload"] = None + state["pending_count"] = 0 + return "accept" + return "pending" + + +class BalQrImportWidget(QWidget): + """Self-contained QR import page: camera or manual frame capture. 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. + into transactions and hands them to :class:`WillTxReviewSignDialog`. 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") - ) + def __init__(self, bal_window, bal_plugin, parent=None, close_cb=None): + QWidget.__init__(self, parent) self.bal_window = bal_window + self.bal_plugin = bal_plugin + self.close_cb = close_cb or (lambda: None) + self._session = None + self._fmt = None + self._key = None self.frames = {} self.total = 0 - self.compressed = False - self._scanning = False self.slot_widgets = {} + self._scanning = False + + # Continuous camera session (started on demand, then hands-free). + self._reader = None + self._camera = None + self._capture_session = None + self._video_sink = None + self._latest_image = None + self._finish_pending = False + self._debounce = { + "last_index": None, + "last_payload": None, + "pending_index": None, + "pending_payload": None, + "pending_count": 0, + } + self._scan_timer = QTimer(self) + self._scan_timer.setInterval(200) # ~5 frames analyzed per second + self._scan_timer.timeout.connect(self._on_scan_tick) 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." + "Show the will QR codes to the camera, one after another.\n" + "After the first code, every new code is captured " + "automatically.\nThe first frame sets the total number of " + "codes; duplicates are ignored." ) ) intro.setWordWrap(True) @@ -2901,13 +3633,15 @@ class WillQrImportDialog(BalDialog): vbox.addLayout(manual) buttons = QHBoxLayout() - self.scan_btn = QPushButton(_("Scan QR with camera")) - self.scan_btn.clicked.connect(self._scan_camera) + self.scan_btn = QPushButton(_("Scan with camera…")) + self.scan_btn.setToolTip( + _( + "Start the live camera. While it runs, every new QR code " + "shown to the camera is captured automatically." + ) + ) + self.scan_btn.clicked.connect(self._toggle_scan) 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) @@ -2920,9 +3654,6 @@ class WillQrImportDialog(BalDialog): 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 ------------------------------------------------------- @@ -2931,31 +3662,72 @@ class WillQrImportDialog(BalDialog): text = self.manual_edit.text().strip() if text: self.manual_edit.clear() - self._add_frame(text) + self._add_frame(text, manual=True) - def _add_frame(self, frame_text): + def _reset_transfer(self, fmt, frame_total): + """Wipe the open transfer because an incompatible frame arrived.""" + 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(frame_total, format_name(fmt)) + ) + + def _add_frame(self, frame_text, manual=False): + """Feed one scanned/pasted frame string into the receive session. + + ``manual`` controls whether parse/malformed failures raise a visible + error (the camera loop fails silently and just keeps scanning). + """ try: - total, index, compressed, payload = parse_frame(frame_text) - except QrTransferError as e: + fmt, key, frame_total, index = parse_for_detection(frame_text) + except AnimatedQrError as e: + if manual: + self.show_error(str(e)) + return "error" + if self._key is not None and key != self._key: + self._reset_transfer(fmt, frame_total) + return "reset" + session = self._session if self._session is not None else AnimatedQrSession() + try: + status = session.add_part(frame_text) + except TransferConflictError: + self._reset_transfer(fmt, frame_total) + return "reset" + except SessionLimitError 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 + return "error" + except AnimatedQrError as e: + if manual: + self.show_error(str(e)) + return "error" + self.total = session.total + if self._session is None: + self._session = session + self._fmt = fmt + self._key = key self._init_slots() - self.frames[index] = payload + if status == "ok": + # Slot grid is always 1-based even for 0-based wire formats. + slot = index + 1 if fmt == "bbqr" else index + self.frames[slot] = frame_text self._update_slots() self._update_status() + return status + + def show_message(self, msg): + """Messagebox shim (this widget is not a dialog).""" + MessageBoxMixin.show_message(self, msg) + + def show_warning(self, msg): + """Warning shim (this widget is not a dialog).""" + MessageBoxMixin.show_warning(self, msg) + + def show_error(self, msg): + """Error shim (this widget is not a dialog).""" + MessageBoxMixin.show_error(self, msg) def _init_slots(self): while self.slot_grid.count(): @@ -2982,10 +3754,15 @@ class WillQrImportDialog(BalDialog): ) def _update_status(self): + session = self._session have = len(self.frames) - if have >= self.total: - self.status_label.setText(_("All {} frames stored.").format(self.total)) + done = session is not None and session.done and have >= self.total + if done: + self.status_label.setText( + _("All {} frames stored.").format(self.total) + ) self.review_btn.setEnabled(True) + self._maybe_auto_finish() else: self.status_label.setText( _("Stored {} of {} frames.").format(have, self.total) @@ -2993,9 +3770,14 @@ class WillQrImportDialog(BalDialog): self.review_btn.setEnabled(False) def _reset_all(self): + self._stop_scan() + self._finish_pending = False + self._session = None + self._fmt = None + self._key = None self.frames = {} self.total = 0 - self.compressed = False + self._reset_debounce() if self.slot_widgets: for b in self.slot_widgets.values(): b.deleteLater() @@ -3006,170 +3788,206 @@ class WillQrImportDialog(BalDialog): # -- capture -------------------------------------------------------------- - def _scan_camera(self): + def _reset_debounce(self): + self._debounce.update( + { + "key": None, + "last_index": None, + "last_payload": None, + "pending_index": None, + "pending_payload": None, + "pending_count": 0, + } + ) + + def _toggle_scan(self): + if self._scanning: + self._stop_scan() + else: + self._start_scan() + + def _start_scan(self): + """Open the camera and start the continuous, hands-free frame loop.""" 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, + from electrum.qrreader import get_qr_reader + from PyQt6.QtMultimedia import ( + QCamera, + QMediaCaptureSession, + QMediaDevices, + QVideoSink, ) 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) + self._reader = get_qr_reader() 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() + device = QMediaDevices.defaultVideoInput() + if not device or device.isNull(): + self.show_error( + _("Cannot start QR scanner, no usable camera found.") + ) + return - def on_success(blob): - if not blob: - return + self._scanning = True + self.scan_btn.setText(_("Stop scanning")) + self._finish_pending = False + self._reset_debounce() + + try: + self._camera = QCamera(device) + self._camera.errorOccurred.connect(self._on_camera_error) + self._capture_session = QMediaCaptureSession() + self._capture_session.setCamera(self._camera) + self._video_sink = QVideoSink(self) + # QVideoSink notifies new frames via videoFrameChanged (videoFrame + # is the frame *getter*, not a signal). + self._video_sink.videoFrameChanged.connect(self._on_video_frame) + self._capture_session.setVideoSink(self._video_sink) + self._camera.start() + self._scan_timer.start() + except Exception as e: + self._stop_scan() + self.show_error(str(e)) + + def _stop_scan(self): + """Release the camera and the continuous loop.""" + self._scanning = False + self._scan_timer.stop() + if self._camera is not None: 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) + self._camera.errorOccurred.disconnect(self._on_camera_error) + except (RuntimeError, TypeError, AttributeError): + pass + self._camera.stop() + if self._video_sink is not None: + try: + self._video_sink.videoFrameChanged.disconnect(self._on_video_frame) + except (RuntimeError, TypeError, AttributeError): + pass + self._camera = None + self._capture_session = None + self._video_sink = None + self._reader = None + self._latest_image = None + self._reset_debounce() + self.scan_btn.setText(_("Scan with camera…")) - kbps = plugin.modem_config.modem_bps / 1e3 - WaitingDialog( - self, - _("Waiting for audio ({:.1f} kbps)…").format(kbps), - receiver_thread, - on_success, + def _on_camera_error(self, error, error_str): + # A failed camera should not silently drop the hands-free session. + if self._scanning: + self._stop_scan() + self.show_error(_("Camera error: {}").format(error_str or error)) + + def _on_video_frame(self, video_frame): + if self._scanning and video_frame.isValid(): + self._latest_image = video_frame.toImage() + + def _on_scan_tick(self): + """Analyze the latest camera frame (~5 times per second).""" + image = self._latest_image + self._latest_image = None + if image is None or self._reader is None or not self._scanning: + return + from PyQt6.QtGui import QImage + + try: + gray = image.convertToFormat(QImage.Format.Format_Grayscale8) + except Exception: + return + try: + results = self._reader.read_qr_code( + gray.constBits().__int__(), + gray.sizeInBytes(), + gray.bytesPerLine(), + gray.width(), + gray.height(), + ) + except Exception: + return + if results: + self._handle_scanned_text(results[0].data) + + def _handle_scanned_text(self, text): + """Route a decoded QR string through the change/detection policy.""" + try: + fmt, key, frame_total, index = parse_for_detection(text) + except AnimatedQrError: + return + decision = qr_import_accept_frame( + self._debounce, fmt, key, frame_total, index, text ) + if decision == "pending": + return + if decision == "reset": + self._reset_all() + self._finish_pending = False + self.show_warning( + _( + "The scanned code belongs to a different transfer ({} " + "frames, {}). The import was reset; show the first code " + "again." + ).format(frame_total, format_name(fmt)) + ) + return + if decision == "accept": + self._add_frame(text, manual=False) + + def _maybe_auto_finish(self): + if self._finish_pending: + return + if not self._scanning: + return + session = self._session + if not self.frames or not self.total: + return + if session is None or not session.done: + return + self._finish_pending = True + self._stop_scan() + # Defer so the widget repaints before the review dialog takes over. + QTimer.singleShot(0, self._review_and_sign) + + def hideEvent(self, event): + # Leaving the QR page (or closing the dialog) must release the camera. + self._stop_scan() + super().hideEvent(event) # -- 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)) + session = self._session + if session is None or not session.done: return - if not tx_strings: + try: + transfer, compressed = session.resolve() + parts = decode_transfer(transfer, compressed) + except (MissingFramesError, QrTransferError, AnimatedQrError) as e: + self.show_error(str(e)) + self._reset_all() + return + if not parts: self.show_error(_("The transferred will contains no transactions.")) return - self._finish_import(tx_strings) + # Join the frames back into an opaque payload; _complete_import + # autodetects whether it is a whole will or a transaction list. + self._finish_import("\n".join(parts)) - 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 + def _finish_import(self, payload): + """Hand the transferred payload to the shared import tail.""" + _complete_import( + self.bal_window, + self.bal_plugin, + payload, + show_error=self.show_error, + show_warning=self.show_warning, + close=self.close_cb, ) - 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. @@ -3374,6 +4192,8 @@ class WillTxReviewSignDialog(BalDialog): if not signed: self.show_message(_("No signed transaction to show.")) return - d = WillQrExportDialog(self.bal_window, will=signed, bal_plugin=self.bal_plugin) + d = WillExportDialog( + self.bal_window, will=signed, bal_plugin=self.bal_plugin, initial_mode="qr" + ) show_on_top(d) diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 11939e8..bb7ece3 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -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,13 +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) - export_menu.addAction(_("QR Codes"), self.export_will_via_qr) - menu.addAction(_("Import"), self.import_will_into_details) - menu.addAction(_("Import via QR"), self.import_will_via_qr) + # 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) @@ -735,48 +733,15 @@ 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() - def export_will_via_qr(self): - self.bal_window.export_will_via_qr() - - def import_will_via_qr(self): - self.bal_window.import_will_via_qr() - def merge_will(self): self.bal_window.merge_will_ui() diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index 120cb56..6c16ada 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -20,10 +20,7 @@ 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, @@ -40,6 +37,8 @@ from .common import ( QWidget, UserCancelled, Willexecutors, + _, + _logger, add_widget, partial, read_QIcon_from_bytes, diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 10ccd16..2e84ef6 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -29,17 +29,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 +55,15 @@ from .common import ( QSpinBox, QStyle, QStyleOptionFrame, + Qt, QTextEdit, QVBoxLayout, QWidget, - Qt, Union, Util, Will, + _, + _logger, char_width_in_lineedit, datetime, getSaveFileName, diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index ab7b10f..24736f3 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -25,8 +25,7 @@ from ...core.checkalive import ( resolve_date_to_check, ) from .common import ( - _, - _logger, + OP_RETURN_PREFIX, AmountException, BalPlugin, Buttons, @@ -40,10 +39,10 @@ from .common import ( Mapping, Network, NoHeirsException, - NoWillExecutorNotPresent, NotCompleteWillException, - OP_RETURN_PREFIX, + NoWillExecutorNotPresent, OkButton, + Optional, PaymentIdentifier, QGridLayout, QLabel, @@ -57,15 +56,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 +74,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,8 +89,10 @@ from .dialogs import ( BalWizardDialog, WillDetailDialog, WillExecutorDialog, - WillQrExportDialog, - WillQrImportDialog, + WillExportDialog, + WillImportDialog, + _complete_import, + decode_will_payload, ) from .lists import HeirListWidget, PreviewList from .widgets import LockTimeWidget, PercAmountEdit @@ -517,11 +520,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) @@ -1638,6 +1641,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( @@ -1647,18 +1663,23 @@ class BalWindow: self.show_error(str(e)) raise e - def export_will_via_qr(self, will=None): - """Export the will (default: the live one) as QR codes on screen. + def export_will_dialog(self, will=None, initial_mode: Optional[str] = None): + """Open the unified export window (File / QR / Audio). - The selected transactions are serialized with the wire format of - :mod:`bal.core.qrtransfer` and shown, one frame at a time, in a - :class:`WillQrExportDialog`. When Electrum's ``audio_modem`` plugin - is available (:meth:`get_audio_modem_plugin`) the dialog also offers - an "Audio" send button. + 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 = WillQrExportDialog(self, will=willitems, bal_plugin=self.bal_plugin) + 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)) @@ -1693,6 +1714,22 @@ class BalWindow: 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. @@ -1816,16 +1853,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: @@ -1835,16 +1890,16 @@ class BalWindow: import_meta_gui(self.window, _("will"), on_file, on_success) - def import_will_via_qr(self): - """Import a will through QR codes (or audio) and review/sign it. + def import_will_dialog(self): + """Open the unified import window (File / QR / Audio). - Opens a :class:`WillQrImportDialog`. The captured transactions are - parsed into fresh :class:`WillItem` objects (never touching the - live will), run through the same local validity pass the merge flow - uses, and are then presented in the per-transaction review wizard - (:class:`WillTxReviewSignDialog`). + 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 = WillQrImportDialog(self, bal_plugin=self.bal_plugin) + d = WillImportDialog(self, bal_plugin=self.bal_plugin) show_on_top(d) def _load_will_file(self, path): @@ -1855,6 +1910,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 diff --git a/pyproject.toml b/pyproject.toml index 5e48620..97a6aae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,9 @@ target-version = "py312" select =["E", "W", "F", "I", "N", "B"] ignore = ["E501"] +[tool.ruff.lint.pep8-naming] +classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls + [tool.ruff.lint.per-file-ignores] "bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports "bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText diff --git a/tests/sim_update_flows.py b/tests/sim_update_flows.py index fb65e58..ed44be9 100644 --- a/tests/sim_update_flows.py +++ b/tests/sim_update_flows.py @@ -21,12 +21,12 @@ Run: QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py """ -import copy import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +from bal.core.util import copy_structure from bal.core.will import ( HeirNotFoundException, NoHeirsException, @@ -58,7 +58,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False): is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx).""" d = { "tx": _VALID_TX_HEX, - "heirs": copy.deepcopy(heirs), + "heirs": copy_structure(heirs), "willexecutor": None, "status": "", "description": "", @@ -67,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False): "baltx_fees": TX_FEES, } item = WillItem(d, _id="willid_1") - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) # Force the locktime frozen "inside" the signed tx. item.tx.locktime = tx_locktime if status_complete: @@ -118,7 +118,7 @@ def main(): # Scenario 0: nothing changed -> should be coherent. heirs = {"alice": ["addr_alice", 5000, same_lt]} _run("0. nothing changed", - will_heirs=heirs, current_heirs=copy.deepcopy(heirs), + will_heirs=heirs, current_heirs=copy_structure(heirs), tx_locktime=base_lt, check_date=0) # Scenario 1: delivery date moved forward (postpone), will NOT yet signed. diff --git a/tests/test_anticipate_manual_locktime.py b/tests/test_anticipate_manual_locktime.py index 9187870..69f4378 100644 --- a/tests/test_anticipate_manual_locktime.py +++ b/tests/test_anticipate_manual_locktime.py @@ -27,7 +27,6 @@ Run: tests/test_anticipate_manual_locktime.py -q """ -import copy import os import sys @@ -35,6 +34,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) import pytest # noqa: E402 # pyright: ignore[reportMissingImports] +from bal.core.util import copy_structure # noqa: E402 from bal.core.will import ( # noqa: E402 NotCompleteWillException, Will, @@ -70,7 +70,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False): """ d = { "tx": _VALID_TX_HEX, - "heirs": copy.deepcopy(heirs), + "heirs": copy_structure(heirs), "willexecutor": None, "status": "", "description": "", @@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False): "baltx_fees": TX_FEES, } item = WillItem(d, _id="willid_1") - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) item.tx.locktime = tx_locktime if status_complete: item.set_status("COMPLETE", True) diff --git a/tests/test_core_animated_qr.py b/tests/test_core_animated_qr.py new file mode 100644 index 0000000..509ab2d --- /dev/null +++ b/tests/test_core_animated_qr.py @@ -0,0 +1,478 @@ +""" +Tests for ``bal.core.animated_qr`` (BC-UR v1, BC-UR v2, BBQR interop). + +Validates the self-contained codecs against the published spec vectors +(BCR-2020-004/005 BC32, BCR-2020-012 bytewords) and against byte-exact +output captured from the reference C++ bc-ur encoder (fountain/xoshiro/ +alias-sampler parity), plus round trips, out-of-order assembly, missing-part +fountain solving and malformed-input rejection for all four formats. + +Run: + source electrum/env/bin/activate + python3 tests/test_core_animated_qr.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +import random + +from bal.core import animated_qr as aq + + +def _payload(plen: int) -> bytes: + """Deterministic payload matching the C++ reference driver (``(i*7)&0xff``).""" + return bytes((i * 7) & 0xFF for i in range(plen)) + + +# --------------------------------------------------------------------------- # +# BC32 (BCR-2020-004 / bcr-2020-005 rev1 reference implementation vectors) +# --------------------------------------------------------------------------- # + + +def test_bc32_official_vectors(): + cases = [ + (b"Hello, world", "fpjkcmr09ss8wmmjd3jq6ax7w9"), + (b"Hello world", "fpjkcmr0ypmk7unvvsh4ra4j"), + ( + bytes.fromhex("d934063e82001eec0585ee41ab5d8e4b703a4be1f73aec21e143912c56"), + "my6qv05zqq0wcpv9aeq6khvwfdcr5jlp7uawcg0pgwgjc4shjm6xu", + ), + ] + for payload, encoded in cases: + assert aq.bc32_encode(payload) == encoded + assert aq.bc32_decode(encoded) == payload + + +def test_bc32_checksum_rejected(): + good = aq.bc32_encode(b"Hello, world") + corrupted = good[:-1] + ("a" if good[-1] != "a" else "b") + try: + aq.bc32_decode(corrupted) + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for corrupted BC32") + + +def test_bc32_bad_char_rejected(): + try: + aq.bc32_decode("1" * 26) + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for '1' (not in alphabet)") + + +# --------------------------------------------------------------------------- # +# Bytewords (BCR-2020-012) +# --------------------------------------------------------------------------- # + + +def test_bytewords_minimal_roundtrip(): + samples = [bytes(range(256)), _payload(59), b"\x00"] + [ + os.urandom(64) for _ in range(4) + ] + for data in samples: + words = aq.bytewords_minimal_encode(data) + assert len(words) == (len(data) + 4) * 2 # 2 chars per byte incl. CRC + assert aq.bytewords_minimal_decode(words) == data + + +def test_bytewords_rejects_corrupted_crc(): + data = _payload(40) + words = aq.bytewords_minimal_encode(data) + flip = "a" if words[-1] != "a" else "b" + try: + aq.bytewords_minimal_decode(words[:-1] + flip) + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for corrupted CRC") + + +def test_bytewords_rejects_odd_length(): + try: + aq.bytewords_minimal_decode("abc") + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for odd-length bytewords") + + +# --------------------------------------------------------------------------- # +# BC-UR v2: byte-exact parity with the reference C++ encoder +# --------------------------------------------------------------------------- # + +# Reference frames from the bc-ur C++ fountain encoder +# (payload x=(i*7)&0xFF, cbor wrapped, single-part and multipart). +REF_V2_SINGLE_12 = "ur:bytes/gsaeatbabzcecndrehetfhfggtoeemhpmo" + +REF_V2_MULTI_59 = [ + "ur:bytes/2-2/lpaoaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeeccasket", + "ur:bytes/3-2/lpaxaocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperpryssimryrldt", + "ur:bytes/4-2/lpaaaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaefeimteue", + "ur:bytes/5-2/lpahaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssgdaontls", + "ur:bytes/6-2/lpamaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssisescmwt", + "ur:bytes/7-2/lpataocsfscyrpdpjzbyhdcthdfraeatbabzcecndrehetfhfggtghhpidinjoktkblplkmunyoypdperprysslsspplgm", + "ur:bytes/8-2/lpayaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeonlrzebg", + "ur:bytes/9-2/lpasaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaeaaryknzt", + "ur:bytes/10-2/lpbkaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsslotsfrfn", + "ur:bytes/11-2/lpbdaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnssdtwyrstd", + "ur:bytes/12-2/lpbnaocsfscyrpdpjzbyhdctsbtdtavtvdwyykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtaegswnvdin", + "ur:bytes/13-2/lpbtaocsfscyrpdpjzbyhdctmuwltavdwlzowlurdtfrdtdihkjekkjlhkdnesdidtuywlzmwluydtdiesdnsshknlptee", +] + +# Reference message for the 59-byte payload: byte-string head (0x58,0x3b) + data. +REF_V2_MULTI_59_MSG = bytes([0x58, 0x3B]) + _payload(59) + + +def test_v2_single_part_matches_reference(): + frames = aq.ur2_frames(_payload(12), len(REF_V2_SINGLE_12)) + assert frames == [REF_V2_SINGLE_12] + + +def test_v2_reference_frames_decode_and_reencode_exactly(): + message = REF_V2_MULTI_59_MSG + fragment_len = -(-len(message) // 2) + for frame in REF_V2_MULTI_59: + seq, seq_len, message_len, checksum, data = aq.ur2_parse_part(frame) + assert seq_len == 2 + assert message_len == len(message) + assert checksum == aq.crc32_int(message) + assert len(data) == fragment_len + # re-encoding the parsed values reproduces the reference line exactly + assert aq._ur2_part_string(seq, seq_len, message_len, checksum, data) == frame + # our choose_fragments + partition + xor reproduces the reference data + indexes = aq.choose_fragments(seq, seq_len, checksum) + assert seq_num_indexes_valid(seq, seq_len, indexes) + mixed = aq._mix_fragments(aq._partition_message(message, fragment_len), indexes, fragment_len) + assert mixed == data + + +def seq_num_indexes_valid(seq, seq_len, indexes): + # pure part for seq <= seq_len contains exactly fragment seq-1 + if seq <= seq_len: + return indexes == {seq - 1} + return set(indexes) <= set(range(seq_len)) and bool(indexes) + + +def test_v2_multipart_encoder_matches_reference_from_seq2(): + # Our frames start at seq 1 (spec-aligned); parts seq 2.. must equal the + # reference (which starts at seq 2 due to first_seq_num=1). + mine = aq.ur2_frames(_payload(59), 120) + assert mine[0].split("/", 1)[1].startswith("1-2") or "1-2" in mine[0].split("/")[1] + assert mine[1:4] == REF_V2_MULTI_59[:3] + + +def test_v2_reference_seq7_mix_parity(): + # Higher-degree mixed parts (seq_len=7) also match: message uses the + # reference head 0x58|0x00 for the 256-byte driver payload. + message = bytes([0x58, 0x00]) + _payload(256) + seq_len = 7 + fragment_len = -(-len(message) // seq_len) + frames = [ + "ur:bytes/9-7/lpasatcfadaocyfysnjlsrhddaykztaxbkbycsctdsdpeefrfwgagdhghyihjzjkknlylomymtntoxpyprrhrtsttotluovlwdwnsrfejzhd", + "ur:bytes/10-7/lpbkatcfadaocyfysnjlsrhddazeahbnbwcycldedlenfsfygrgmhkhniojtkpkelslememkneolpmqzrksasotitsuevwwpwfzswzpmdrvo", + "ur:bytes/11-7/lpbdatcfadaocyfysnjlsrhddawkwtbbbefnaefnbebbjojybebnaebndybbbewkwtceaecedyeebebbjobnaebnbeeedybbbeztwproyapd", + ] + for frame in frames: + seq, sl, mlen, checksum, data = aq.ur2_parse_part(frame) + assert sl == seq_len and mlen == len(message) + assert checksum == aq.crc32_int(message) + mixed = aq._mix_fragments( + aq._partition_message(message, fragment_len), + aq.choose_fragments(seq, seq_len, checksum), + fragment_len, + ) + assert mixed == data + + +# --------------------------------------------------------------------------- # +# BC-UR v2: sessions / fountain decoding +# --------------------------------------------------------------------------- # + + +def test_v2_roundtrip_in_order(): + payload = ("BAL transfer " * 9).encode() + frames = aq.ur2_frames(payload, 120) + seq_len = int(frames[0].split("/")[1].split("-")[1]) + assert len(frames) == 2 * seq_len # pure wave + redundant mixed wave + session = aq.AnimatedQrSession() + for frame in frames: + session.add_part(frame) + assert session.done + assert session.received == session.total + text, _ = session.resolve() + assert text == payload.decode() + + +def test_v2_out_of_order_and_duplicate(): + payload = ("BAL transfer " * 9).encode() + frames = aq.ur2_frames(payload, 120) + order = list(range(len(frames))) + random.Random(11).shuffle(order) + session = aq.AnimatedQrSession() + for i in order: + status = session.add_part(frames[i]) + assert status in ("ok", "dup") + session.add_part(frames[0]) # duplicate of an already-received part + assert session.done + assert session.resolve()[0] == payload.decode() + + +def test_v2_solves_without_a_pure_fragment(): + payload = ("BAL transfer " * 9).encode() + frames = aq.ur2_frames(payload, 120) + session = aq.AnimatedQrSession() + for frame in frames[1:]: # drop the first pure fragment + session.add_part(frame) + assert session.done + assert session.resolve()[0] == payload.decode() + + +def test_v2_single_part_import(): + session = aq.AnimatedQrSession() + session.add_part(REF_V2_SINGLE_12) + assert session.done and session.total == 1 + assert session.resolve()[0] == _payload(12).decode("latin-1") + + +def test_v2_conflicting_transfer_rejected(): + payload_a = b"AAAAAAAAAAAAAAAA" + payload_b = b"BBBBBBBBBBBBBBBB" + fa = aq.ur2_frames(payload_a, 500)[0] + fb = aq.ur2_frames(payload_b, 500)[0] + session = aq.AnimatedQrSession() + session.add_part(fa) + try: + session.add_part(fb) + except aq.TransferConflictError: + pass + else: + raise AssertionError("expected TransferConflictError for a different transfer") + + +def test_v2_corrupt_crc_rejected(): + frame = list(REF_V2_MULTI_59[0]) + idx = len(frame) - 1 + frame[idx] = "a" if frame[idx] != "a" else "b" + try: + aq.ur2_parse_part("".join(frame)) + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for a corrupt v2 part") + + +def test_v2_session_cap_rejected(): + part = aq._ur2_part_string(1, 30000, 100, 1234, b"\x00" * 100) + session = aq._Ur2Session() + try: + session.add(part) + except aq.SessionLimitError: + pass + else: + raise AssertionError("expected SessionLimitError for oversized seq_len") + + +# --------------------------------------------------------------------------- # +# BC-UR v1 +# --------------------------------------------------------------------------- # + + +def test_v1_multipart_roundtrip(): + payload = ("v1 transfer payload " * 6).encode() + frames = aq.ur1_frames(payload, 120) + assert len(frames) > 1 + session = aq.AnimatedQrSession() + for frame in reversed(frames): + session.add_part(frame) + assert session.done + assert session.resolve()[0] == payload.decode() + + +def test_v1_single_part_roundtrip(): + payload = b"hello, bal" + frames = aq.ur1_frames(payload, 400) + assert len(frames) == 1 + session = aq.AnimatedQrSession() + session.add_part(frames[0]) + assert session.done and session.total == 1 + assert session.resolve()[0] == payload.decode() + + +def test_v1_headerless_single_part_import(): + # bcr-2020-005 rev1 allows omitting the sequence header + digest entirely. + payload = b"hello, bal" + message = aq.cbor_byte_string(payload) + single = "ur:bytes/" + aq.bc32_encode(message) + assert aq.detect_format(single) == "ur1" + session = aq.AnimatedQrSession() + session.add_part(single) + assert session.done + assert session.resolve()[0] == payload.decode() + + +def test_v1_digest_mismatch_rejected(): + frame = aq.ur1_frames(b"hello, bal", 400)[0] + tampered = frame[:-4] + "abcd" + session = aq.AnimatedQrSession() + session.add_part(tampered) + try: + session.resolve() + except aq.ChecksumError: + pass + else: + raise AssertionError("expected ChecksumError for a tampered v1 digest") + + +def test_v1_part_numbers_validated(): + for bad in ( + "ur:bytes/0of1/{}full".format("x" * 51), + "ur:bytes/2of1/{}full".format("x" * 51), + "ur:bytes/1of0/{}full".format("x" * 51), + "ur:bytes/1aof1/{}full".format("x" * 51), + ): + try: + aq.ur1_parse_part(bad) + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for: {}".format(bad)) + + +# --------------------------------------------------------------------------- # +# BBQR +# --------------------------------------------------------------------------- # + + +def test_bbqr_all_encodings_roundtrip(): + payload = ("BBQR payload " * 8).encode() + for encoding in ("Z", "2", "H"): + frames = aq.bbqr_frames(payload, 90, encoding=encoding) + assert len(frames) >= 1 + order = list(range(len(frames))) + random.Random(3).shuffle(order) + session = aq.AnimatedQrSession() + for i in order: + session.add_part(frames[i]) + assert session.done + assert session.resolve()[0] == payload.decode() + + +def test_bbqr_compression_default_and_fallback(): + payload = ("repetitive data " * 40).encode() # compresses well + frames_z = aq.bbqr_frames(payload, 90, encoding="Z") + # Highly compressible: Z yields one frame and a 'Z' flag. + assert all(f[2] == "Z" for f in frames_z) + assert len(frames_z) == 1 + raw = os.urandom(600) # incompressible + frames_2 = aq.bbqr_frames(raw, 90, encoding="Z") + assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used + + +def test_bbqr_hex_uppercase(): + payload = b"\xde\xad\xbe\xef" + frame = aq.bbqr_frames(payload, 50, encoding="H")[0] + assert "DEADBEEF" in frame + encoding, _type, total, index, frag = aq.bbqr_parse_part(frame) + assert (encoding, total, index) == ("H", 1, 0) + + +def test_bbqr_runt_last_part(): + payload = os.urandom(33) + frames = aq.bbqr_frames(payload, 60, encoding="2") + parts = [aq.bbqr_parse_part(f)[4] for f in frames] + joined = aq._bbqr_decode(parts, "2") + assert joined == payload + assert len(parts[-1]) < len(parts[0]) # last part is a runt + + +def test_bbqr_zlib_bomb_rejected(): + compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1] + try: + aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data + except aq.AnimatedQrError: + pass + # direct inflate bomb guard: + inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z") + assert inflated[0] == "Z" # 1MB zeros compresses + bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1] + parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)] + try: + aq._bbqr_decode(parts, "Z") + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for an oversized decompression") + + +def test_bbqr_part_number_limits(): + try: + aq.bbqr_frames(os.urandom(30000), 40, encoding="2") + except aq.AnimatedQrError: + pass + else: + raise AssertionError("expected AnimatedQrError for too many BBQR parts") + + +# --------------------------------------------------------------------------- # +# Detection / parse_for_detection +# --------------------------------------------------------------------------- # + + +def test_detect_format_recognises_all_formats(): + assert aq.detect_format("BALQR1|1|1||payload") == "balqr" + assert aq.detect_format(aq.ur1_frames(b"x", 400)[0]) == "ur1" + assert aq.detect_format(aq.ur2_frames(b"x", 400)[0]) == "ur2" + assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr" + assert aq.detect_format(REF_V2_SINGLE_12) == "ur2" + assert aq.detect_format("ur:bytes/" + aq.bc32_encode(aq.cbor_byte_string(b"x"))) == "ur1" + + +def test_detect_format_rejects_garbage(): + for text in ("", "hello world", "BALQ|1|1||a", "ur:", "ur:txn/xyz"): + assert aq.detect_format(text) is None, text + # Lenient prefix probe: a string that merely *starts* with "balqr" is + # reported as balqr (the strict parse then rejects it downstream). + assert aq.detect_format("BALQRX|1|1||a") == "balqr" + + +def test_parse_for_detection_keys(): + bal = aq.parse_for_detection("BALQR1|3|2||payload") + assert bal == ("balqr", "balqr:3", 3, 2) + v2 = aq.parse_for_detection(aq.ur2_frames(b"x"*50, 400)[0]) + assert v2[0] == "ur2" and v2[2] == 1 and v2[3] == 1 + v1 = aq.parse_for_detection(aq.ur1_frames(b"x"*50, 120)[0]) + assert v1[0] == "ur1" and v1[2] > 1 and 1 <= v1[3] <= v1[2] + bb = aq.parse_for_detection(aq.bbqr_frames(b"x"*50, 40)[0]) + assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2] + + +def test_format_names_exist(): + for fmt in ("balqr", "ur1", "ur2", "bbqr"): + assert aq.format_name(fmt) + assert aq.format_name("nope") == "nope" + + +# --------------------------------------------------------------------------- # +if __name__ == "__main__": + import traceback + + failures = 0 + for _name, fn in sorted(globals().items()): + if _name.startswith("test_") and callable(fn): + try: + fn() + print("ok: {}".format(_name)) + except Exception: + failures += 1 + print("FAIL: {}".format(_name)) + traceback.print_exc() + if failures: + print("{} test(s) failed".format(failures)) + sys.exit(1) + print("all tests passed") diff --git a/tests/test_core_plugin_base.py b/tests/test_core_plugin_base.py index 37305d0..4a00d24 100644 --- a/tests/test_core_plugin_base.py +++ b/tests/test_core_plugin_base.py @@ -14,7 +14,7 @@ import time sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp @@ -74,7 +74,7 @@ def test_bt_to_date_absolute(): def test_bt_to_date_relative(): - now = datetime.now() + now = datetime.now(timezone.utc) # relative days from now bt = BalTimestamp("7d") @@ -86,8 +86,8 @@ def test_bt_to_date_relative(): d_rev = bt.to_date(reverse=True) assert d_rev < now - # from explicit datetime - base = datetime(2025, 6, 1, 12, 0, 0) + # from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable) + base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) d = bt.to_date(from_date=base) expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0) assert d == expected @@ -101,7 +101,7 @@ def test_bt_to_date_relative(): def test_bt_to_date_years(): bt = BalTimestamp("1y") d = bt.to_date() - assert d > datetime.now() + assert d > datetime.now(timezone.utc) def test_bt_to_date_overflow(): diff --git a/tests/test_core_qr_transfer.py b/tests/test_core_qr_transfer.py index 51d924b..b7de47b 100644 --- a/tests/test_core_qr_transfer.py +++ b/tests/test_core_qr_transfer.py @@ -293,7 +293,6 @@ def test_presets_fit_qrcode_ec_m(): """Every preset budget must render inside a QR at EC level M.""" try: import qrcode - from qrcode.constants import ERROR_CORRECT_M except ImportError: print("qrcode not installed - skipping capacity check") @@ -324,4 +323,4 @@ if __name__ == "__main__": if failures: print("{} test(s) failed".format(failures)) sys.exit(1) - print("all tests passed") \ No newline at end of file + print("all tests passed") diff --git a/tests/test_core_will.py b/tests/test_core_will.py index 832679d..94a7249 100644 --- a/tests/test_core_will.py +++ b/tests/test_core_will.py @@ -8,12 +8,12 @@ Run: python3 tests/test_core_will.py """ -import copy import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +from bal.core.util import copy_structure from bal.core.will import Will, WillItem # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) @@ -48,7 +48,7 @@ def _make_willitem_blank(): """Create a fresh WillItem from scratch.""" item = WillItem(_make_minimal_willitem_dict()) # Reset STATUS to clean defaults - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) return item @@ -151,8 +151,8 @@ def test_will_only_valid_list(): def _make_will_with_heirs(heirs, tx_locktime): """Build a single-item will whose stored heirs == ``heirs`` and whose frozen tx.locktime == ``tx_locktime`` (what the will-executors hold).""" - item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs))) - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs))) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) item.tx.locktime = tx_locktime return {"willid_1": item} @@ -163,7 +163,7 @@ def test_check_heirs_unchanged_is_coherent(): heirs = {"alice": ["addr_alice", 5000, str(lt)]} will = _make_will_with_heirs(heirs, lt) result = Will.check_willexecutors_and_heirs( - will, copy.deepcopy(heirs), {}, False, 0, 100 + will, copy_structure(heirs), {}, False, 0, 100 ) assert result is True diff --git a/tests/test_core_will_invalidate.py b/tests/test_core_will_invalidate.py index b6ca4ca..6a93cab 100644 --- a/tests/test_core_will_invalidate.py +++ b/tests/test_core_will_invalidate.py @@ -17,7 +17,6 @@ Run: python3 -m pytest tests/test_core_will_invalidate.py -q """ -import copy import os import sys from unittest.mock import MagicMock, patch @@ -75,7 +74,7 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None): "change": "", "baltx_fees": 100, }) - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) # Set the input value so the balance calculation works. # Use the name-mangled attribute because tx_from_any creates a # Transaction whose inputs are TxInput objects; TxInput.value_sats() @@ -270,7 +269,7 @@ class TestInvalidateWill: """ item = _make_willitem(value_sats=100) will = {"willtxid1": item} - wallet = _mock_wallet([_make_utxo()]) + wallet = _mock_wallet([_make_utxo(value_sats=100)]) result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100) diff --git a/tests/test_group_e_karen7_invalidate.py b/tests/test_group_e_karen7_invalidate.py index e635831..a8f7d74 100644 --- a/tests/test_group_e_karen7_invalidate.py +++ b/tests/test_group_e_karen7_invalidate.py @@ -21,7 +21,6 @@ Run: python3 -m pytest tests/test_group_e_karen7_invalidate.py -q """ -import copy import json import os import sys @@ -46,6 +45,7 @@ from electrum.transaction import ( from electrum.util import bfh from bal.core.heirs import Heirs +from bal.core.util import copy_structure from bal.core.will import Will, WillItem # ------------------------------------------------------------------ # @@ -219,7 +219,7 @@ def _txs_to_will(txs, heirs_data): for txid, tx in txs.items(): item_dict = { "tx": tx, - "heirs": copy.deepcopy(heirs_data), + "heirs": copy_structure(heirs_data), "willexecutor": None, "status": "", "description": "", diff --git a/tests/test_group_e_mock_karen7.py b/tests/test_group_e_mock_karen7.py index ff844a7..a63981a 100644 --- a/tests/test_group_e_mock_karen7.py +++ b/tests/test_group_e_mock_karen7.py @@ -22,7 +22,6 @@ Run: python3 -m pytest tests/test_group_e_mock_karen7.py -q """ -import copy import json import os import sys @@ -43,6 +42,7 @@ from bal.core.reminders import ( ical_escape, write_temp_ics, ) +from bal.core.util import copy_structure from bal.core.will import HeirNotFoundException, Will, WillItem from bal.core.willexecutors import Willexecutors @@ -136,7 +136,7 @@ def _make_willitem(**overrides): } d.update(overrides) item = WillItem(d) - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) return item @@ -343,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild(): item = WillItem( { "tx": _VALID_TX_HEX, - "heirs": copy.deepcopy(will_heirs), + "heirs": copy_structure(will_heirs), "willexecutor": None, "status": "", "description": "", @@ -352,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild(): "baltx_fees": 100, } ) - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) item.tx.locktime = lt will = {"willid_1": item} diff --git a/tests/test_gui_export_dialogs.py b/tests/test_gui_export_dialogs.py new file mode 100644 index 0000000..bf48e16 --- /dev/null +++ b/tests/test_gui_export_dialogs.py @@ -0,0 +1,456 @@ +""" +Tests for the filter-based unified export/import dialogs and the BalWindow +transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``). + +Covers the shared export filters (All / Valid / Valid NC), the unified +``WillExportDialog`` file page (whole item vs tx-only content, empty-filter +abort), the audio export/import pages (KB/sec wiring, missing plugin guard, +receive flow) and the comma-separated tx-only file writer. The audio pages +run against a stub plugin so no sound hardware is exercised. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py +""" + +import base64 +import json +import sys +import zlib +from unittest.mock import patch + +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from PyQt6.QtWidgets import QApplication, QMainWindow + +import bal.gui.qt.dialogs as dialogs +from bal.core.qrtransfer import CHUNK_PRESETS + +_app = QApplication.instance() or QApplication(sys.argv) + +# A valid 1x1 transparent PNG, good enough for BalDialog's window icon. +_PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + +# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, +# version 2); used for real-WillItem serialization tests. +_VALID_TX_HEX = ( + "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" + "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" + "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" + "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" + "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" + "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" + "42146f11ef8414ae929feaafc388ac00000000" +) + + +class _Cfg: + def __init__(self, value): + self._value = value + + def get(self): + return self._value + + +class FakePlugin: + QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1]) + + def read_file(self, path): + return _PNG_BYTES + + +class FakeWindow(QMainWindow): + config = {} + + def format_amount(self, amount): + return "{:.8f}".format(amount) + + def format_amount_and_units(self, amount): + return "{:.8f} sat".format(amount) + + +class FakeAudioPlugin: + """Duck-typed ``audio_modem`` plugin for the audio pages.""" + + def __init__(self): + self.modem_config = None + + def is_available(self): + return True + + +class _FakeModemConfig: + def __init__(self, kbps): + self.modem_bps = kbps * 1000 + + +class FakeBalWindow: + """Duck-typed stand-in for BalWindow (dialog layer only).""" + + def __init__(self, audio_plugin=None): + self.window = FakeWindow() + self.bal_plugin = FakePlugin() + self.willitems = {} + self.audio_plugin = audio_plugin + self.bitrate_set = None + self.audio_payloads = [] + + def get_audio_modem_plugin(self): + return self.audio_plugin + + def set_audio_modem_bitrate(self, kbps): + self.bitrate_set = kbps + if self.audio_plugin is not None: + self.audio_plugin.modem_config = _FakeModemConfig(kbps) + + def _audio_send_payload(self, payload): + self.audio_payloads.append(payload) + + def export_json_file(self, path, will=None): + items = will if will is not None else self.willitems + with open(path, "w", encoding="utf-8") as f: + json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f) + + def export_tx_file(self, path, will=None): + items = will if will is not None else self.willitems + with open(path, "w", encoding="utf-8") as f: + f.write(",".join(str(wi.tx) for _, wi in items.items())) + + +class StubTx: + def __init__(self, payload): + self.payload = payload + + def txid(self): + return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF) + + def __str__(self): + return self.payload + + +class StubWillItem: + def __init__(self, payload, statuses=None): + self.tx = StubTx(payload) + self.statuses = statuses or {} + + def get_status(self, name): + return self.statuses.get(name, False) + + def to_dict(self): + return {"tx": str(self.tx)} + + +def _make_willitems(n=3, payload_len=60, statuses=None): + return { + "item{}".format(i): StubWillItem( + "T{}".format(i) * payload_len, statuses=statuses + ) + for i in range(n) + } + + +# ------------------------------------------------------------------ # +# Shared export filters +# ------------------------------------------------------------------ # + +def test_export_filter_options(): + opts = dialogs.export_filter_options() + assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"] + complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True}) + valid = StubWillItem("V*", statuses={"VALID": True}) + plain = StubWillItem("P*") + assert opts[0][1](complete) and opts[0][1](plain) + assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain) + assert not opts[2][1](complete) + assert opts[2][1](valid) and not opts[2][1](plain) + + +def test_filter_willitems_by_index(): + items = { + "a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}), + "b": StubWillItem("B*", statuses={"VALID": True}), + "c": StubWillItem("C*"), + } + opts = dialogs.export_filter_options() + assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"} + assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"} + assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]} + + +# ------------------------------------------------------------------ # +# WillExportDialog file page +# ------------------------------------------------------------------ # + +def test_file_export_selects_by_filter(): + bw = FakeBalWindow() + items = _make_willitems(3) + items["item0"].statuses = {"VALID": True, "COMPLETE": True} + items["item1"].statuses = {"VALID": True} + d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin) + assert set(d._selected_items()) == set(items) + + d._on_filter_change(1) + assert set(d._selected_items()) == {"item0", "item1"} + d._on_filter_change(2) + assert list(d._selected_items()) == ["item1"] + d.close() + + +def test_file_export_run_tx_only(tmpdir): + bw = FakeBalWindow() + items = _make_willitems(3) + d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin) + d.content_check.setChecked(False) + captured = {} + + def fake_gui(window, title, exporter): + captured["title"] = title + captured["exporter"] = exporter + + with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui): + d._export_file() + assert captured["title"] == "will_tx" + out = tmpdir.join("will_tx.txt").strpath + captured["exporter"](out) + expected = ",".join(str(wi.tx) for _, wi in items.items()) + assert open(out, encoding="utf-8").read() == expected + d.close() + + +def test_file_export_run_willitem(tmpdir): + bw = FakeBalWindow() + items = _make_willitems(2) + d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin) + assert d.content_check.isChecked() + captured = {} + + def fake_gui(window, title, exporter): + captured["title"] = title + captured["exporter"] = exporter + + with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui): + d._export_file() + assert captured["title"] == "will" + out = tmpdir.join("will.json").strpath + captured["exporter"](out) + data = json.load(open(out, encoding="utf-8")) + assert set(data) == set(items) + assert data["item0"]["tx"] == str(items["item0"].tx) + d.close() + + +def test_file_export_empty_under_filter_aborts(): + bw = FakeBalWindow() + items = { + "a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}) + } + d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin) + messages = [] + d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment] + d._filter_index = 2 # force an empty "Valid NC" selection + with patch.object(dialogs, "export_meta_gui") as gui: + d._export_file() + assert not gui.called + assert messages + d.close() + + +# ------------------------------------------------------------------ # +# BalWindow transport helpers +# ------------------------------------------------------------------ # + +def test_bal_window_export_tx_file(tmpdir): + import bal.gui.qt.window as window + + items = _make_willitems(3) + bw = object.__new__(window.BalWindow) + bw.willitems = items + out = tmpdir.join("will_tx.txt").strpath + bw.export_tx_file(out) + expected = ",".join(str(wi.tx) for _, wi in items.items()) + assert open(out, encoding="utf-8").read() == expected + + +def test_bal_window_set_audio_modem_bitrate(): + try: + import amodem.config + except ImportError: + return + import bal.gui.qt.window as window + + class P: + def __init__(self): + self.modem_config = None + + probe = P() + bw = object.__new__(window.BalWindow) + bw.get_audio_modem_plugin = lambda: probe + bw.set_audio_modem_bitrate(1) + assert probe.modem_config is amodem.config.bitrates[1] + + +# ------------------------------------------------------------------ # +# WillExportDialog audio page +# ------------------------------------------------------------------ # + +def test_audio_export_page_send(): + bw = FakeBalWindow(audio_plugin=FakeAudioPlugin()) + items = _make_willitems(3, payload_len=30) + bw.willitems = items + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) + assert d.audio_send_btn.text() == dialogs._("Send") + assert d.audio_send_btn.isEnabled() + assert d.kbps_combo.count() > 0 + + kbps = int(d.kbps_combo.currentText()) + d._send_audio() + assert bw.bitrate_set == kbps + # Whole-will default: the audio payload is a single JSON document. + assert len(bw.audio_payloads) == 1 + data = json.loads(bw.audio_payloads[0]) + assert set(data) == set(items) + d.close() + + +def test_audio_export_page_send_tx_only(): + bw = FakeBalWindow(audio_plugin=FakeAudioPlugin()) + items = _make_willitems(3, payload_len=30) + bw.willitems = items + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) + d.content_check.setChecked(False) + + kbps = int(d.kbps_combo.currentText()) + d._send_audio() + assert bw.bitrate_set == kbps + expected = "\n".join(dialogs.serialize_tx_list(items)) + assert bw.audio_payloads == [expected] + d.close() + + +def test_audio_export_page_plugin_missing(): + # The window stays usable: only the audio option is disabled. + bw = FakeBalWindow(audio_plugin=None) + bw.willitems = _make_willitems(2) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) + assert not d.audio_send_btn.isEnabled() + assert "not available" in d.audio_warn_label.text() + assert len(d.qr_page.frames) >= 1 # QR still usable + assert d.file_export_btn.isEnabled() + d.close() + + +# ------------------------------------------------------------------ # +# WillImportDialog audio page +# ------------------------------------------------------------------ # + +def test_audio_import_page_build(): + bw = FakeBalWindow(audio_plugin=FakeAudioPlugin()) + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + assert d.kbps_combo.count() > 0 + assert d.receive_btn.text() == dialogs._("Receive by audio…") + assert d.receive_btn.isEnabled() + d.close() + + +def test_audio_import_page_plugin_missing(): + bw = FakeBalWindow(audio_plugin=None) + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + assert not d.receive_btn.isEnabled() + assert "not available" in d.audio_warn_label.text() + assert d.qr_page is not None # QR import still usable + d.close() + + +def test_audio_import_receive_wiring(): + bw = FakeBalWindow(audio_plugin=FakeAudioPlugin()) + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + captured = {} + + class FakeWaitingDialog: + def __init__(self, parent, msg, task, on_success=None, on_error=None): + captured["msg"] = msg + captured["task"] = task + captured["success"] = on_success + captured["error"] = on_error + + imported = [] + + def fake_complete(bal_window, bal_plugin, payload, **kwargs): + imported.append(payload) + + blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40) + with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object( + dialogs, "_complete_import", side_effect=fake_complete + ): + d._audio_receive() + kbps = int(d.kbps_combo.currentText()) + assert bw.bitrate_set == kbps + assert captured["task"] is not None + captured["success"](blob) + # Payload is the raw decompressed text; autodetect handles the splitting. + assert imported == ["A" * 40 + "\n" + "B" * 40] + d.close() + + +# ------------------------------------------------------------------ # +# Whole-will JSON payload serializes a real Transaction (MyEncoder) +# ------------------------------------------------------------------ # + +def test_qr_whole_will_json_serializes_transaction(): + """Regression: _whole_will_json must not raise + "Object of type Transaction is not JSON serializable". + + Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will + QR payload (default content scope) must serialize it via MyEncoder the + same way write_json_file does. + """ + from bal.core.will import WillItem + + item = WillItem({ + "tx": _VALID_TX_HEX, + "heirs": {}, + "willexecutor": None, + "status": "", + "description": "", + "time": 0, + "change": "", + "baltx_fees": 100, + }) + bw = FakeBalWindow() + d = dialogs.WillExportDialog( + bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr" + ) + j = d._whole_will_json() + data = json.loads(j) + assert data["imp0"]["tx"] == _VALID_TX_HEX + d.close() + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + import inspect + import os + import tempfile + + class _Path: + def __init__(self, d, name): + self.strpath = os.path.join(d, name) + + class _Tmp: + def __init__(self): + self._d = tempfile.mkdtemp() + + def join(self, name): + return _Path(self._d, name) + + tmp = _Tmp() + for name in sorted(dir()): + if name.startswith("test_"): + fn = globals()[name] + fn(tmp) if inspect.signature(fn).parameters else fn() + print(" [OK] {}".format(name)) + print("[OK] All export dialog GUI tests passed") diff --git a/tests/test_gui_qr_transfer.py b/tests/test_gui_qr_transfer.py index d73b762..226fb89 100644 --- a/tests/test_gui_qr_transfer.py +++ b/tests/test_gui_qr_transfer.py @@ -1,19 +1,21 @@ """ Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``). -Covers WillQrExportDialog (build, frame navigation, chunk-size change) and -WillQrImportDialog (frame capture, slot grid, complete-review enabling, total -mismatch reset, frame assembly -> decode). The wizard and the camera/audio -paths need a live wallet/hardware and are exercised only through the shared -frame-assembly path here. +Covers the unified ``WillExportDialog`` (transport radios, stacked pages, +QR build/navigation, chunk-size / autoplay, filter revert) and the unified +``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling, +total mismatch reset, frame assembly -> decode). The wizard and the +camera/audio paths need a live wallet/hardware and are exercised only +through the shared frame-assembly path here. Run: QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py """ import base64 +import json import sys -from unittest.mock import patch +from unittest.mock import MagicMock, patch sys.path.insert(0, __file__.rsplit("/", 2)[0]) @@ -21,7 +23,7 @@ from electrum.transaction import Transaction from PyQt6.QtWidgets import QApplication, QMainWindow import bal.gui.qt.dialogs as dialogs -from bal.core.qrtransfer import encode_transfer, split_frames +from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer, split_frames from bal.core.will import WillItem _app = QApplication.instance() or QApplication(sys.argv) @@ -44,7 +46,18 @@ _VALID_TX_HEX = ( ) +class _Cfg: + def __init__(self, value): + self._value = value + + def get(self): + return self._value + + class FakePlugin: + # Smallest QR preset: long transfers produce several frames. + QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1]) + def read_file(self, path): return _PNG_BYTES @@ -90,6 +103,9 @@ class StubWillItem: def get_status(self, name): return self.statuses.get(name, False) + def to_dict(self): + return {"tx": str(self.tx), "status": self.statuses} + def _make_willitems(n=3, payload_len=120): return { @@ -99,30 +115,61 @@ def _make_willitems(n=3, payload_len=120): # ------------------------------------------------------------------ # -# WillQrExportDialog +# WillExportDialog (QR transport via d.qr_page) # ------------------------------------------------------------------ # def test_export_dialog_builds(): bw = FakeBalWindow() bw.willitems = _make_willitems() - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) - assert d.tx_strings - assert d.frames - assert len(d.frames) >= 1 + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + assert d.transport_qr.isChecked() + assert page.tx_strings + assert page.frames + assert len(page.frames) >= 1 # Frame 1 is shown. - assert d.qr_view.text == d.frames[0] - assert "1" in d.progress_label.text() + assert page.qr_view.text == page.frames[0] + assert "1" in page.progress_label.text() + d.close() + + +def test_export_dialog_unified_transports(): + # One window hosts the three transports as stacked, radio-selected pages. + bw = FakeBalWindow() + bw.willitems = _make_willitems() + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) + assert d.stacked.count() == 3 + assert d.transport_file.isChecked() + assert d.stacked.currentWidget() is d.file_page + d._on_mode_clicked(d.MODE_QR) + assert d.stacked.currentWidget() is d.qr_page + d._on_mode_clicked(d.MODE_AUDIO) + assert d.stacked.currentWidget() is d.audio_page + d.close() + + +def test_import_dialog_qr_page_has_no_audio(): + # Audio lives on the import dialog's own audio page, never in the QR page. + bw = FakeBalWindow() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + assert not hasattr(page, "_audio_receive") + texts = [b.text() for b in page.findChildren(dialogs.QPushButton)] + assert not any("Audio" in t for t in texts) + assert d.receive_btn is not None d.close() def test_export_dialog_empty_close(): - # An empty will shows a modal message; stub it out for the test. + # An empty will shows a modal message and no widgets are built; stub the + # message out for the test. orig = dialogs.MessageBoxMixin.show_message dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None try: bw = FakeBalWindow() - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin) assert not d.isVisible() + assert not hasattr(d, "qr_page") d.close() finally: dialogs.MessageBoxMixin.show_message = orig @@ -144,50 +191,52 @@ def test_imported_item_status_not_none(): def test_export_auto_scroll(): bw = FakeBalWindow() bw.willitems = _make_willitems(n=6, payload_len=400) - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) - assert d.fps_spin is not None - assert not d.auto_timer.isActive() + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + assert page.fps_spin is not None + assert not page.auto_timer.isActive() - d.fps_spin.setValue(2) - d._toggle_auto() - assert d.auto_timer.isActive() - assert d.auto_btn.text() == dialogs._("Stop") - d._auto_step() - assert d.index == 1 - d._toggle_auto() - assert not d.auto_timer.isActive() - assert d.auto_btn.text() == dialogs._("Auto") + page.fps_spin.setValue(2) + page._toggle_auto() + assert page.auto_timer.isActive() + assert page.auto_btn.text() == dialogs._("Stop") + page._auto_step() + assert page.index == 1 + page._toggle_auto() + assert not page.auto_timer.isActive() + assert page.auto_btn.text() == dialogs._("Auto") # Advancing past the last frame stops the slideshow automatically. - d._toggle_auto() - d.index = len(d.frames) - 1 - d._auto_step() - assert not d.auto_timer.isActive() + page._toggle_auto() + page.index = len(page.frames) - 1 + page._auto_step() + assert not page.auto_timer.isActive() d.close() def test_export_auto_scroll_loop(): bw = FakeBalWindow() bw.willitems = _make_willitems(n=6, payload_len=400) - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) - assert d.loop_check is not None + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + assert page.loop_check is not None # Loop on: reaching the last code wraps back to the first and keeps going. - d.loop_check.setChecked(True) - d._toggle_auto() - assert d.auto_timer.isActive() - d.index = len(d.frames) - 1 - d._auto_step() - assert d.index == 0 - assert d.auto_timer.isActive() - d._toggle_auto() + page.loop_check.setChecked(True) + page._toggle_auto() + assert page.auto_timer.isActive() + page.index = len(page.frames) - 1 + page._auto_step() + assert page.index == 0 + assert page.auto_timer.isActive() + page._toggle_auto() # Loop off: reaching the last code stops the slideshow. - d.loop_check.setChecked(False) - d._toggle_auto() - d.index = len(d.frames) - 1 - d._auto_step() - assert not d.auto_timer.isActive() + page.loop_check.setChecked(False) + page._toggle_auto() + page.index = len(page.frames) - 1 + page._auto_step() + assert not page.auto_timer.isActive() d.close() @@ -197,18 +246,27 @@ def test_export_filter_valid_and_valid_nc(): b = StubWillItem("B" * 120, statuses={"VALID": True}) c = StubWillItem("C" * 120) bw.willitems = {"a": a, "b": b, "c": c} - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) - assert len(d.tx_strings) == 3 + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page assert d.filter_combo.count() == 3 + # Whole-will default: a single JSON document carrying all selected items. + assert d.content_check.isChecked() + assert len(page.tx_strings) == 1 + data = json.loads(page.tx_strings[0]) + assert set(data) == {"a", "b", "c"} + + # Switch to tx-only content, then exercise the filters. + d.content_check.setChecked(False) + assert len(page.tx_strings) == 3 # "Valid" filter -> only the valid items (a, b). d._on_filter_change(1) - assert sorted(d.tx_strings) == ["A" * 120, "B" * 120] + assert sorted(page.tx_strings) == ["A" * 120, "B" * 120] # "Valid NC" filter -> only the valid, not-complete item (b). d._on_filter_change(2) - assert sorted(d.tx_strings) == ["B" * 120] - assert d.qr_view.text == d.frames[0] + assert sorted(page.tx_strings) == ["B" * 120] + assert page.qr_view.text == page.frames[0] d.close() @@ -218,116 +276,464 @@ def test_export_filter_empty_reverts(): bw.willitems = { "a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) } - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") messages = [] - d.show_message = lambda msg: messages.append(msg) + d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment] d._on_filter_change(2) # "Valid NC" -> empty subset assert messages assert d._filter_index == 0 # reverted to "All" assert d.filter_combo.currentIndex() == 0 - assert len(d.tx_strings) == 1 + assert len(d.qr_page.tx_strings) == 1 d.close() def test_export_navigation_and_chunk_change(): bw = FakeBalWindow() bw.willitems = _make_willitems(n=6, payload_len=400) - d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) - first_count = len(d.frames) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + first_count = len(page.frames) assert first_count > 1 # long transfer, small default chunk - assert not d.prev_btn.isEnabled() - d._next() - assert d.index == 1 - assert d.qr_view.text == d.frames[1] - assert d.prev_btn.isEnabled() - d._prev() - assert d.index == 0 - assert d.qr_view.text == d.frames[0] + assert not page.prev_btn.isEnabled() + page._next() + assert page.index == 1 + assert page.qr_view.text == page.frames[1] + assert page.prev_btn.isEnabled() + page._prev() + assert page.index == 0 + assert page.qr_view.text == page.frames[0] # Switch to the largest preset: fewer, bigger frames. - d._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1) - assert len(d.frames) < first_count - assert d.index == 0 + page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1) + assert len(page.frames) < first_count + assert page.index == 0 d.close() # ------------------------------------------------------------------ # -# WillQrImportDialog +# WillImportDialog (QR transport via d.qr_page) # ------------------------------------------------------------------ # def test_import_frame_flow(): bw = FakeBalWindow() - d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) - assert not d.review_btn.isEnabled() - assert not d.slot_area.isVisible() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + assert not page.review_btn.isEnabled() + assert not page.slot_area.isVisible() transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120]) frames = split_frames(transfer, 150) assert len(frames) > 1 for frame in frames: - d._add_frame(frame) - assert d.total == len(frames) - assert d.review_btn.isEnabled() + page._add_frame(frame) + assert page.total == len(frames) + assert page.review_btn.isEnabled() # isVisible() needs a shown parent; assert the widget is not hidden instead. - assert not d.slot_area.isHidden() - assert len(d.slot_widgets) == d.total - assert "All" in d.status_label.text() + assert not page.slot_area.isHidden() + assert len(page.slot_widgets) == page.total + assert "All" in page.status_label.text() # Duplicate capture is harmless. - d._add_frame(frames[0]) - assert len(d.frames) == d.total + page._add_frame(frames[0]) + assert len(page.frames) == page.total d.close() def test_import_assembles_and_decodes(): bw = FakeBalWindow() - d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page captured = {} - def fake_finish(tx_strings): - captured["tx_strings"] = tx_strings + def fake_finish(payload): + captured["payload"] = payload - d._finish_import = fake_finish + page._finish_import = fake_finish transfer = encode_transfer(["P" * 130, "Q" * 130]) for frame in split_frames(transfer, 150): - d._add_frame(frame) - d._review_and_sign() - assert captured["tx_strings"] == ["P" * 130, "Q" * 130] + page._add_frame(frame) + page._review_and_sign() + # The payload is rejoined into an opaque string for autodetect. + assert captured["payload"].split("\n") == ["P" * 130, "Q" * 130] d.close() +def test_decode_will_payload_autodetect(): + # Whole-will JSON is recognized as a will document. + payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}}) + kind, data = dialogs.decode_will_payload(payload) + assert kind == "will" + assert data["item1"]["tx"] == "AAAA" + + # A singleton dict whose value is not an item dict falls back to txs. + kind, data = dialogs.decode_will_payload('{"foo": 1}') + assert kind == "txs" + + # Comma and/or newline separated transactions. + kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC") + assert kind == "txs" + assert data == ["AAAA", "BBBB", "CCCC"] + + # A single transaction with no separators. + kind, data = dialogs.decode_will_payload("HEXHEX") + assert kind == "txs" + assert data == ["HEXHEX"] + + +def test_whole_will_qr_roundtrip(): + # "Whole will" produces a single JSON document that survives a full + # QR encode -> frame capture -> assemble -> decode cycle. + bw = FakeBalWindow() + items = _make_willitems(2) + bw.willitems = items + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + assert d.content_check.isChecked() + assert len(page.tx_strings) == 1 + + # Rebuild the transfer from the dialog's own strings, as the importer does. + transfer = encode_transfer(page.tx_strings) + impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + import_page = impl.qr_page + for frame in split_frames(transfer, dialogs.CHUNK_PRESETS[0][1]): + import_page._add_frame(frame) + assert import_page.review_btn.isEnabled() + caught = {} + + def fake_finish(payload): + caught["payload"] = payload + + import_page._finish_import = fake_finish + import_page._review_and_sign() + kind, data = dialogs.decode_will_payload(caught["payload"]) + assert kind == "will" + assert set(data) == {"item0", "item1"} + d.close() + impl.close() + + def test_import_total_mismatch_resets(): bw = FakeBalWindow() - d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page warnings = [] - d.show_warning = lambda msg: warnings.append(msg) + page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment] frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150) for frame in frames_a: - d._add_frame(frame) - assert d.total == len(frames_a) + page._add_frame(frame) + assert page.total == len(frames_a) # A frame with a different total wipes the import; the first frame of # the new transfer must be scanned afresh. - d._add_frame(frames_b[0]) + page._add_frame(frames_b[0]) assert warnings - assert d.total == 0 - assert not d.frames - assert not d.review_btn.isEnabled() + assert page.total == 0 + assert not page.frames + assert not page.review_btn.isEnabled() d.close() def test_import_manual_entry(): bw = FakeBalWindow() - d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page frames = split_frames(encode_transfer(["M" * 120]), 150) - d.manual_edit.setText(frames[0]) - d._add_from_manual() - assert d.manual_edit.text() == "" - assert d.total == 1 - assert d.review_btn.isEnabled() + page.manual_edit.setText(frames[0]) + page._add_from_manual() + assert page.manual_edit.text() == "" + assert page.total == 1 + assert page.review_btn.isEnabled() + d.close() + + +# ------------------------------------------------------------------ # +# Continuous camera scan (change/detection debounce + auto-finish) +# ------------------------------------------------------------------ # + +def _fresh_debounce(): + return { + "last_index": None, + "last_payload": None, + "pending_index": None, + "pending_payload": None, + "pending_count": 0, + } + + +def test_qr_import_debounce_pending_then_accept(): + s = _fresh_debounce() + # First sighting of a new identity: pending, not yet stored. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "pending" + assert s["pending_count"] == 1 + assert s["last_index"] is None + # A second stable read of the same identity: accepted. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "PAYLOAD1") == "accept" + assert s["last_index"] == 1 + assert s["last_payload"] == "PAYLOAD1" + assert s["pending_count"] == 0 + + +def test_qr_import_debounce_re_reading_last_is_ignored(): + s = _fresh_debounce() + dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") + dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accepted + # The exporter is still showing frame 1: must be ignored, not accepted. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") == "ignore" + assert s["last_index"] == 1 + assert s["pending_count"] == 0 + + +def test_qr_import_debounce_transition_pending_resets(): + s = _fresh_debounce() + dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") + dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 1, "P1") # accept frame 1 + # A new identity interrupts the pending accumulation. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "pending" + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "accept" + # Same-index duplicate with different payload is treated as new identity. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:2", 2, 2, "P2") == "ignore" + + +def test_qr_import_debounce_total_mismatch_resets(): + s = _fresh_debounce() + # In-range frame is accepted even though its declared total is ignored. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "pending" + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:3", 3, 2, "P2") == "accept" + # A frame that belongs to a different transfer. + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "reset" + # Once the policy is rebased on the new transfer, frames resume normally + # (the widget clears the debounce while wiping the import). + s["key"] = None + assert dialogs.qr_import_accept_frame(s, "balqr", "balqr:4", 4, 2, "P2B") == "pending" + + +def test_qr_import_handle_scanned_text_autofinish(): + bw = FakeBalWindow() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + reviewed = [] + page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] + + frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) + assert len(frames) > 1 + + # The camera session is running and every frame needs two stable reads. + page._scanning = True + for frame in frames: + for _rep in range(2): + page._handle_scanned_text(frame) + assert page.total == len(frames) + assert len(page.frames) == len(frames) + assert page.review_btn.isEnabled() + + # With all frames stored, the loop auto-finishes exactly once. + _app.processEvents() + assert reviewed == [True] + # The camera loop was stopped before handing over to the review step. + assert not page._scanning + assert not page._scan_timer.isActive() + d.close() + + +def test_qr_import_handle_scanned_text_manual_does_not_autofinish(): + # Without a camera session running, extra frames never auto-proceed. + bw = FakeBalWindow() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + reviewed = [] + page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] + + frames = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) + assert len(frames) > 1 + assert not page._scanning + for frame in frames: + for _rep in range(2): + page._handle_scanned_text(frame) + assert len(page.frames) == len(frames) + _app.processEvents() + assert reviewed == [] + d.close() + + +# ------------------------------------------------------------------ # +# Animated-QR formats (BC-UR v1/v2, BBQR) via the export/import pages +# ------------------------------------------------------------------ # + +def test_export_format_combo_switches_codecs(): + bw = FakeBalWindow() + bw.willitems = _make_willitems(n=6, payload_len=400) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + assert page.format == "balqr" + assert page.frames[0].startswith("BALQR1|") + assert page.format_combo.count() == 4 + + page._on_format_change(1) # BC-UR v1 + assert page.format == "ur1" + assert page.frames[0].startswith("ur:bytes/") + assert page.index == 0 + assert page.qr_view.text == page.frames[0] + + page._on_format_change(2) # BC-UR v2 + assert page.format == "ur2" + assert page.frames[0].startswith("ur:bytes/") + + page._on_format_change(3) # BBQR + assert page.format == "bbqr" + assert page.frames[0].startswith("B$") + d.close() + + +def test_export_animated_format_frames_fit_budget(): + bw = FakeBalWindow() + bw.willitems = _make_willitems(n=6, payload_len=400) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + for index in range(1, 4): + page._on_format_change(index) + for frame in page.frames: + assert len(frame) <= dialogs.CHUNK_PRESETS[0][1] + d.close() + + +def _import_roundtrip_fmt(fmt_index): + bw = FakeBalWindow() + bw.willitems = _make_willitems(2) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + page._on_format_change(fmt_index) + frames = list(page.frames) + assert frames + d.close() + + impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + import_page = impl.qr_page + caught = {} + import_page._finish_import = lambda payload: caught.__setitem__("payload", payload) + for frame in frames: + import_page._add_frame(frame) + assert import_page.review_btn.isEnabled() + import_page._review_and_sign() + kind, data = dialogs.decode_will_payload(caught["payload"]) + assert kind == "will" + assert set(data) == {"item0", "item1"} + impl.close() + + +def test_import_ur1_roundtrip(): + _import_roundtrip_fmt(1) + + +def test_import_ur2_roundtrip(): + _import_roundtrip_fmt(2) + + +def test_import_bbqr_roundtrip(): + _import_roundtrip_fmt(3) + + +def test_import_animated_scan_debounce_autofinish(): + bw = FakeBalWindow() + bw.willitems = _make_willitems(2) + d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr") + page = d.qr_page + page._on_format_change(2) # BC-UR v2 fountain + frames = list(page.frames) + d.close() + + impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + import_page = impl.qr_page + reviewed = [] + import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment] + import_page._scanning = True + for frame in frames: + for _rep in range(2): + import_page._handle_scanned_text(frame) + assert import_page.review_btn.isEnabled() + # The fountain transfer's part count is ``len(frames) // 2`` (pure + one + # redundant mixed wave). + assert import_page.total == len(frames) // 2 + assert len(import_page.frames) >= import_page.total + assert import_page.review_btn.isEnabled() + _app.processEvents() + assert reviewed == [True] + assert not import_page._scanning + impl.close() + + +def test_import_garbage_scan_is_ignored(): + bw = FakeBalWindow() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + for garbage in ("hello world", "12345", "B$ZZ", ""): + page._handle_scanned_text(garbage) + assert not page.frames + assert page.total == 0 + assert not page.review_btn.isEnabled() + d.close() + + +def test_import_different_animated_transfer_resets(): + bw = FakeBalWindow() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + warnings = [] + page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment] + + frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) + frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150) + for frame in frames_a: + page._add_frame(frame) + assert page.total == len(frames_a) + assert not warnings + + page._add_frame(frames_b[0]) + assert warnings + assert page.total == 0 + assert not page.frames + assert not page.review_btn.isEnabled() + d.close() + + +def test_import_start_stop_scan_signal_wiring(): + """Regression: _start_scan/_stop_scan must use the QVideoSink signal + videoFrameChanged, not the videoFrame frame getter. + + On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so + ``.videoFrame.connect(...)`` raises AttributeError. This test drives the + real sink life-cycle with a mocked camera and asserts the scan session + starts/ends cleanly with no error. + """ + from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices + + bw = FakeBalWindow() + d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin) + page = d.qr_page + errors = [] + page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment] + + fake_device = MagicMock() + fake_device.isNull.return_value = False + + with ( + patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device), + # Mock camera + capture session; the QVideoSink stays real so the + # videoFrameChanged connect/disconnect wiring is exercised for real. + patch.object(QCamera, "__new__", return_value=MagicMock()), + patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()), + ): + page._start_scan() + assert page._scanning is True + assert not errors + + page._stop_scan() + assert page._scanning is False + assert page._camera is None + assert page._video_sink is None + assert not errors d.close() diff --git a/tests/test_heir_relative_anchor.py b/tests/test_heir_relative_anchor.py index 163f6e2..4b36660 100644 --- a/tests/test_heir_relative_anchor.py +++ b/tests/test_heir_relative_anchor.py @@ -28,7 +28,6 @@ Run: python3 tests/test_heir_relative_anchor.py """ -import copy import json import os import sys @@ -40,6 +39,7 @@ from electrum import constants # noqa: E402 (path insert above) constants.net = constants.BitcoinRegtest from bal.core.checkalive import resolve_date_to_check # noqa: E402 +from bal.core.util import copy_structure # noqa: E402 from bal.core.will import ( # noqa: E402 HeirNotFoundException, NoHeirsException, @@ -71,7 +71,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False): is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx).""" d = { "tx": _VALID_TX_HEX, - "heirs": copy.deepcopy(heirs), + "heirs": copy_structure(heirs), "willexecutor": None, "status": "", "description": "", @@ -80,7 +80,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False): "baltx_fees": 1, } item = WillItem(d, _id="willid_1") - item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) + item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT) item.tx.locktime = tx_locktime if status_complete: item.set_status("COMPLETE", True) @@ -111,7 +111,7 @@ def test_unchanged_relative_recipe_signed_is_coherent(): read as a postpone just because the clock has advanced past build day.""" heirs = {"alice": ["addr_alice", 5000, "1y"]} outcome = _run_heir_check( - copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True + copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=True ) assert outcome.startswith("coherent"), outcome @@ -119,7 +119,7 @@ def test_unchanged_relative_recipe_signed_is_coherent(): def test_unchanged_relative_recipe_unsigned_is_coherent(): heirs = {"alice": ["addr_alice", 5000, "1y"]} outcome = _run_heir_check( - copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False + copy_structure(heirs), copy_structure(heirs), _FROZEN, status_complete=False ) assert outcome.startswith("coherent"), outcome @@ -145,7 +145,7 @@ def test_relative_recipe_shortened_on_signed_is_rebuild(): def test_unchanged_absolute_recipe_is_coherent(): built = {"alice": ["addr_alice", 5000, str(_FROZEN)]} outcome = _run_heir_check( - copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True + copy_structure(built), copy_structure(built), _FROZEN, status_complete=True ) assert outcome.startswith("coherent"), outcome @@ -176,6 +176,7 @@ def test_karen7_frozen_delivery_not_expired(): valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" wi = WillItem(data["will"][valid_wid], _id=valid_wid) built_locktime = Will.get_min_locktime({valid_wid: wi}) + assert built_locktime is not None assert built_locktime == int(wi.tx.locktime) date_to_check = resolve_date_to_check( @@ -195,7 +196,6 @@ def test_karen7_unchanged_heirs_are_coherent(): signed tx: the plugin must NOT ask to invalidate the will.""" data = _load_karen7() valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" - wi = WillItem(data["will"][valid_wid], _id=valid_wid) # Use _FROZEN (a UTC-midnight value) so the check is compatible with # the UTC anchoring code. frozen_locktime = _FROZEN diff --git a/tests/test_import_will_details.py b/tests/test_import_will_details.py index 2ce01f3..ec48699 100644 --- a/tests/test_import_will_details.py +++ b/tests/test_import_will_details.py @@ -127,6 +127,11 @@ def test_sign_transactions_external_only(): wallet=FakeWallet(), waiting_dialog=SimpleNamespace(update=lambda msg: None), ) + # sign_transactions dispatches to self._prepare_and_sign_tx; bind the real + # implementation onto the fake so the external-sign run actually executes. + fake._prepare_and_sign_tx = MethodType( + window_mod.BalWindow._prepare_and_sign_tx, fake + ) result = window_mod.BalWindow.sign_transactions(fake, None, will=imported) diff --git a/tests/test_no_willexecutor_karen7.py b/tests/test_no_willexecutor_karen7.py index 47a87d8..7288da4 100644 --- a/tests/test_no_willexecutor_karen7.py +++ b/tests/test_no_willexecutor_karen7.py @@ -27,7 +27,6 @@ Run:: python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s """ -import copy import json import logging import os @@ -49,6 +48,7 @@ from electrum.transaction import PartialTxInput, TxOutpoint from electrum.util import bfh from bal.core.heirs import Heirs +from bal.core.util import copy_structure from bal.core.will import ( NotCompleteWillException, NoWillExecutorNotPresent, @@ -278,11 +278,11 @@ class FakeBalWindow: 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) diff --git a/tests/test_reproduce_none_type.py b/tests/test_reproduce_none_type.py index 6553fda..c575ddf 100644 --- a/tests/test_reproduce_none_type.py +++ b/tests/test_reproduce_none_type.py @@ -7,7 +7,6 @@ but without requiring a full Qt event loop. """ import contextlib -import copy import json import os import sys @@ -24,6 +23,7 @@ if os.path.isdir(ELECTRUM_DIR): from bal.core.heirs import Heirs from bal.core.plugin_base import BalPlugin, BalTimestamp +from bal.core.util import copy_structure from bal.core.will import ( NoHeirsException, NotCompleteWillException, @@ -145,11 +145,11 @@ class FakeBalWindow: 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)