From d288b553ef8dda40a8bc17902048ce06c2eb13ec Mon Sep 17 00:00:00 2001 From: svatantrya Date: Fri, 28 Aug 2026 16:28:37 -0400 Subject: [PATCH 1/5] core+gui: chunked multi-QR will transfer (export/import + review/sign wizard, audio channel) BALQR-framed chunk scheduler (bal/core/qrtransfer.py) with zlib compression, four chunk presets and a QR_CHUNK_SIZE setting (row 16). Export/import dialogs (WillQrExportDialog/WillQrImportDialog), per-tx review-and-sign wizard, export filters (All/Valid/Valid-NC) and an Auto slideshow with speed + loop for the QR codes; optional audio_modem channel with a local receive mirror. _prepare_and_sign_tx refactor (no behaviour change); WillItem.status defaults to '' instead of None (import crash fix); invalidate_will guard for a missing date_to_check. Docs: PLAN_QR_TRANSFER, QML_PLAN, README, HANDOFF, CHANGELOG entry 56, AUDIO_MODEM_DEBIAN. --- AUDIO_MODEM_DEBIAN.md | 170 +++++++ CHANGELOG.md | 105 +++++ HANDOFF.md | 27 ++ PLAN_QR_TRANSFER.md | 482 +++++++++++++++++++ QML_PLAN.md | 379 +++++++++++++++ README.md | 12 + bal/core/plugin_base.py | 7 + bal/core/qrtransfer.py | 212 +++++++++ bal/core/will.py | 2 +- bal/gui/qt/common.py | 2 + bal/gui/qt/dialogs.py | 840 ++++++++++++++++++++++++++++++++- bal/gui/qt/lists.py | 8 + bal/gui/qt/plugin.py | 45 ++ bal/gui/qt/window.py | 209 +++++--- tests/test_core_qr_transfer.py | 327 +++++++++++++ tests/test_gui_qr_transfer.py | 343 ++++++++++++++ 16 files changed, 3106 insertions(+), 64 deletions(-) create mode 100644 AUDIO_MODEM_DEBIAN.md create mode 100644 PLAN_QR_TRANSFER.md create mode 100644 QML_PLAN.md create mode 100644 bal/core/qrtransfer.py create mode 100644 tests/test_core_qr_transfer.py create mode 100644 tests/test_gui_qr_transfer.py diff --git a/AUDIO_MODEM_DEBIAN.md b/AUDIO_MODEM_DEBIAN.md new file mode 100644 index 0000000..8244d87 --- /dev/null +++ b/AUDIO_MODEM_DEBIAN.md @@ -0,0 +1,170 @@ +# Audio MODEM on Debian — setup & troubleshooting + +How to make the optional **audio channel** of BAL (and Electrum's own +`audio_modem` plugin) work on Debian/Ubuntu. The channel lets you send a will +to another device as acoustic OFDM tones instead of scanning QR codes. + +Recommended reading before starting: `CHANGELOG.md` entry 56 +(Audio-environment notes) and `HANDOFF.md` (Dev-box audio prerequisites). + +--- + +## 1. What you need (three independent pieces) + +| Piece | Provides | Where it comes from | +|--------------------------|--------------------------------------------|--------------------------------------| +| `amodem` (Python) | OFDM modulation/demodulation | `pip install amodem` (any venv) | +| `libportaudio.so` | sound I/O backend used by `amodem.audio` | Debian package `libportaudio2` (+ dev symlink, see §2) | +| Electrum `audio_modem` | the plugin whose `_send`/`_recv` BAL reuses | built into Electrum | + +BAL shows the audio buttons only when the plugin is **enabled** (Tools → +Plugins → Audio Modem) and `amodem` is importable. + +> On a headless/CI box there is no speaker/mic, but the channel can still be +> verified with the **sink-monitor loopback** in §4. + +--- + +## 2. The two line fixes (this is the part everyone forgets) + +Debian ships a versioned `libportaudio.so.2` but **not** the unversioned +`libportaudio.so` that old `amodem` code uses, and `amodem` uses NumPy APIs +removed in NumPy 2.x. Both fail **silently** (the plugin's `_send` runs the +load inside a `WaitingDialog` thread without an `on_error` handler). + +### 2a. PortAudio unversioned symlink + +Install the dev package (creates the unversioned symlink), or create it by +hand: + +```bash +sudo apt install libportaudio2 libportaudio-dev # preferred +# or, without the package: +sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 \ + /usr/lib/x86_64-linux-gnu/libportaudio.so +``` + +Verify: + +```bash +source /home/steal/devel/bal/electrum/env/bin/activate +python3 -c "import amodem.audio; print(amodem.audio.Interface(config=None).load('libportaudio.so').call('GetVersionText'))" +# b'PortAudio V19...' <-- success +``` + +> **No-sudo alternative** (fine for one-shot tests): point `LD_LIBRARY_PATH` +> at a directory containing a `libportaudio.so` symlink to the `.so.2`: +> ```bash +> mkdir -p /tmp/portaudio_stub +> ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so +> export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH +> ``` + +### 2b. amodem vs NumPy 2.x (`tostring` removed) + +`amodem` 1.16.0 calls `numpy.ndarray.tostring()`, removed in NumPy 2.x +(≥ 2.4.6 dies with `AttributeError` on the first sample write, so **no carrier +is ever emitted**). Either pin NumPy < 2, or patch the single line in the +installed package: + +```bash +source /home/steal/devel/bal/electrum/env/bin/activate +python3 -m pip install "numpy<2" # option A (downgrade) +# option B (patch; path depends on your site-packages): +sed -i "s/sym.astype('int16').tostring()/sym.astype('int16').tobytes()/" \ + /home/steal/devel/bal/electrum/env/lib/python3.11/site-packages/amodem/common.py +``` + +> This must be done on **every** machine that receives/sends audio (both ends +> of the channel use the same code), and again after reinstalling/upgrading +> `amodem`. + +--- + +## 3. Environment checklist (dev box, already applied) + +These were applied on the current dev box and do NOT need to be re-done: + +- `amodem` installed in the runtime venv (`1.16.0`). +- `amodem/common.py` patched `tostring()` → `tobytes()`. +- System symlink or `LD_LIBRARY_PATH` stub for `libportaudio.so`. +- PulseAudio running; default sink `ALC236 Analog`, default source DMIC. + +Check them in one command: + +```bash +source /home/steal/devel/bal/electrum/env/bin/activate +python3 - <<'EOF' +import amodem, ctypes, numpy, zlib +print("amodem", amodem.__version__) +print("numpy", numpy.__version__, "(2.x needs the tobytes patch)") +import amodem.audio +amodem.audio.Interface(config=None).load("libportaudio.so") +print("libportaudio.so loaded OK (symlink or LD_LIBRARY_PATH in place)") +EOF +``` + +--- + +## 4. Verifying the channel (no speakers/mic needed) + +Full **send → sink → sink-monitor → recv** round-trip on one machine: + +```bash +# 1) route capture at the loop and remember the original source +MON="$(pactl get-default-sink).monitor"; ORIG=$(pactl get-default-source) +pactl set-default-source "$MON" + +# 2) run the round-trip (uses zlib-compressed payload like the plugin) +source /home/steal/devel/bal/electrum/env/bin/activate +timeout 90 python3 /tmp/opencode/bal_audio_loopback.py +# expected: bitrate 1.0 kbps ... send done ... RECV OK + +# 3) restore the original source +pactl set-default-source "$ORIG" +``` + +Any payload you like: `python3 /tmp/opencode/bal_audio_loopback.py "BALQR|1|1|0|hi"`. + +With real speakers + mic instead, skip the `pactl` swapping, put the devices +close, keep volumes high, and run the same script. + +--- + +## 5. Testing through the real GUI + +1. **Tools → (Plugins) → Audio Modem** → enable it. If asked for settings, + pick a bitrate: default `slowest()` is ~1.0–1.2 kbps (a ~2 KB will takes + ~15–20 s of audio); higher bitrates are faster but less robust. +2. Wallet A → BAL will list → **Export → QR Codes → Audio…** + (the audio transport sends the raw newline-joined tx list, no BAL framing). +3. Wallet B → will list → **Import via QR → Audio…** → wait for + "Waiting for audio (... kbps)…", a loading cursor while demodulating, + then the decoded slots appear → review/sign wizard opens. +4. One machine only: apply the §4 monitor trick in the shell where Electrum + runs (export plays to the sink; import records from the sink monitor). + +--- + +## 6. Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub | +| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer | +| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines | +| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` | +| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog | + +--- + +## 7. No-sudo quick reference (all commands) + +```bash +python3 -m pip install amodem +mkdir -p /tmp/portaudio_stub +ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so +export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH +# numpy >= 2 (one of): +pip install "numpy<2" # or patch amodem/common.py tobytes +``` \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 24623fd..fb054f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2908,3 +2908,108 @@ renumber the grid rows. - Full test suite: 438 passed (unchanged). **Outcome:** DONE. + +## 56. QR / audio will transfer (chunked multi-QR export/import + review-and-sign wizard) + +**Date:** 2026-08-27 + +**Goal (owner request):** export a will to another device via QR codes (with +an optional audio channel on top), and import it there with a per-transaction +review-and-sign flow. QR codes are chunked because a full will usually exceeds +a single code's capacity. + +**What changed:** + +- `bal/core/qrtransfer.py` (new, GUI-free): the wire format and chunk + scheduler — `BALQR{version}|{total}|{index}|{flags}|{payload}` frames, + `encode_transfer` / `decode_transfer`, `split_frames` / `parse_frame` / + `assemble`, optional `Z` (zlib+base64) compression at export, four chunk + presets (150/400/900/1800 bytes/frame, EC level M), plus + `preset_index_for_chunk_size` and the `QrTransferError` exception family. +- `tests/test_core_qr_transfer.py` (new): round-trips (plain/compressed), + boundaries (exact-fit, size>payload, `|` in payload), min-size guard, bad + magic/version/numbers/flags, multi-frame reassembly, header consistency. +- `bal/core/plugin_base.py`: new `QR_CHUNK_SIZE` config (default 150). +- `bal/gui/qt/plugin.py`: settings-dialog row 16 "QR Code Size" combo + (4 presets) + reset button, visible in BASIC and ADVANCED. +- `bal/gui/qt/dialogs.py`: + - `BalQrImage`: QR widget with MEDIUM error correction (Electrum's + `QRCodeWidget` is EC-L), reusing `draw_qr`. + - `WillQrExportDialog`: walks the frames (Prev/Next, "i of N" progress), + export filters **All / Valid / Valid-NC**, live chunk-preset selector, + **Auto slideshow** (toggle button + "QR codes per second" spinbox, stops on + the last frame and on filter/chunk changes), optional audio-send button. + - `WillQrImportDialog`: camera scan (Electrum `scan_qrcode_from_camera`, + one code at a time), manual paste fallback, slot grid (1..N) with + green=stored, total-mismatch reset, optional audio-receive that mirrors + the audio_modem `_recv` sink with a callback instead of `setText`. + - `WillTxReviewSignDialog`: per-transaction review (outputs via + `get_ui_address_str`, total outputs, fee) with Sign / Skip / Cancel and a + single wallet password; final page offers "Save signed file…" and + "Show signed QR…". Runs on the imported local copy only. +- `bal/gui/qt/window.py`: `export_will_via_qr`, `import_will_via_qr`, + `get_audio_modem_plugin`, `_audio_send_payload`; `sign_transactions` + refactored into a byte-equivalent batch loop plus the reusable + `_prepare_and_sign_tx(…, txid, password)` single-transaction helper. +- `bal/gui/qt/lists.py`: will-list menu gains **Export → QR Codes** and + **Import via QR**. +- `tests/test_gui_qr_transfer.py` (new): export build/navigation/chunk + change, filters (Valid, Valid-NC, empty-revert), import frame flow, + assembly+decode, total-mismatch reset, manual entry. +- `PLAN_QR_TRANSFER.md`: the full spec (wire format, settings, export, + import, wizard checklist P0–P6, findings log). +- Docs: `README.md` QR-transfer section; `QML_PLAN.md` updated (Phase 2 + `BalQrTransferModel`, Phase 3 dedicated QML export/import pages, R6 + mitigation rewritten, deferred-chunks note removed). + +**Audio channel caveats:** +- The audio send/receive buttons only appear when Electrum's `audio_modem` + plugin is enabled *and* `amodem` + PortAudio are installed (not present in + the current dev runtime — verified F22). On that channel the transport + zlib-compresses internally, so no BAL framing/`Z` flag is used. +- `WaitingDialog` requires a real `QWidget` parent and the plugin's `_recv` + hard-wires `parent.setText`, so receive uses a local mirror with a + callback sink. + +**Verification:** +- `python3 tests/test_core_qr_transfer.py`: all pass. +- `QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py`: all pass. +- Pytest batch `tests/test_core_*.py tests/test_gui_*.py`: 374 passed (only + the two pre-existing `test_bt_to_date_*` datetime compare failures remain). +- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py + electrum.plugins.bal`: passed (clean import under real Electrum). +- `ruff`: no new violations on changed files. + +**Audio-environment notes (dev box, discovered while testing):** +- `amodem` 1.16.0 is old and uses `np.ndarray.tostring()`, removed in numpy 2.x; + the runtime venv (numpy 2.4.6) needs the one-line patch + `tostring()` → `tobytes()` in + `electrum/env/lib/python3.11/site-packages/amodem/common.py` (done locally, + not in the repo). Any machine with numpy>=2 and this amodem version needs + the same patch (or numpy<2). +- Electrum's `audio_modem` plugin hardcodes `libportaudio.so` (unversioned). + Debian/Ubuntu only ship `libportaudio.so.2`, so the load fails silently + inside the plugin's `_send` `WaitingDialog` (no `on_error` → no sound, no + message). Fix on the dev box: + `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so` + (created by the `libportaudio-dev` package; a `LD_LIBRARY_PATH` stub works + without root). The audio buttons stay hidden unless the plugin is enabled + and available. +- Verified on the dev box (no physical mic required) via a full + send→sink→monitor→recv round-trip: set the default PulseAudio source to + `.monitor` at receive time; payload returned byte-identical. + +**Follow-up fixes (same session, reported during audio testing):** +- `WillItem.__init__` now defaults `status` to `""` instead of `None`. A + `WillItem` built from a bare `{"tx": ...}` (QR/audio import, clipboard + merge) crashed in `set_status` with + `unsupported operand type(s) for +=: 'NoneType' and 'str'` during the + validity pass / `IMPORTED` marking. +- `BalWindow.invalidate_will` guards a missing `date_to_check` (first-action + case) like `merge_will` already did, fixing + `AttributeError: 'BalWindow' object has no attribute 'date_to_check'` when + invalidating before the periodic check initialized it. +- Regression test `test_imported_item_status_not_none` added to + `tests/test_gui_qr_transfer.py`; QR GUI suite 12/12, batch 377 passed. + +**Outcome:** DONE. diff --git a/HANDOFF.md b/HANDOFF.md index 2784bf6..f639ab0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -392,3 +392,30 @@ See Section 5 for details. push to `origin/main`, then run `./make-release.sh` to create the Gitea **Release** with the ZIP + signatures attached (it becomes the owner's "Latest" download). Always give the owner the Release URL. + +### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`) + +- P0–P4 of `PLAN_QR_TRANSFER.md` are implemented on + `feature/bal-qr-transfer` and un-committed: core scheduler + (`bal/core/qrtransfer.py`), settings row, export/import dialogs + + review/sign wizard + lists/window wiring, all with tests + (`tests/test_core_qr_transfer.py`, `tests/test_gui_qr_transfer.py`) and + docs (README, CHANGELOG entry 56, QML_PLAN, this file). +- Remaining: **P6** release hygiene — `python3 build_zip.py`, external-zip + test, full ruff check (no NEW violations; baseline is intentionally dirty), + and a manual on-device walkthrough of the QR path. The audio path is + optional and hidden until the `audio_modem` plugin + `amodem` are + installed. +- Commit policy unchanged: nothing is committed until the owner explicitly + confirms. + +**Dev-box audio prerequisites (audio_modem channel):** +See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the +two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal): +- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so` + (the unversioned name the plugin loads; Debian ships only `.so.2`). +- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` → + `tobytes()` (already applied locally). Both are runtime-env fixes, not repo + changes; see CHANGELOG entry 56. +- To loop-test on one machine without speakers/mic: during receive, + `pactl set-default-source .monitor` (restore after). diff --git a/PLAN_QR_TRANSFER.md b/PLAN_QR_TRANSFER.md new file mode 100644 index 0000000..a4b1a8d --- /dev/null +++ b/PLAN_QR_TRANSFER.md @@ -0,0 +1,482 @@ +# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned) + +> Goal: let the user move an inheritance ("will") between devices over two +> air-gap channels: +> +> 1. **QR codes** (primary): export the **valid** inheritance transactions as +> a sequence of QR codes, and import them back on another machine with the +> camera; +> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is +> enabled): send/receive the same payload through the PC speaker + +> microphone. +> +> Both channels converge on the same review-and-sign flow afterwards. +> +> Status: APPROVED by owner (2026-08-25). No code written yet — this document +> is the implementation contract. Work top-down through §9 Checklist. +> +> Chat language: Italian; this document is in English (global rule R1). + +--- + +## 1. Scope + +### In scope + +- **Desktop PyQt6 GUI** (primary, implemented by this plan): + - New plugin setting: QR chunk size, offered as **4 standard presets**. + - *"Export via QR"* action: serializes the **valid** will transactions, + optionally compresses, splits the resulting string into fixed-size frames, + shows one QR at a time with prev/next navigation, live re-chunking and + progress (`i di N`). + - *"Import via QR"* action: camera capture dialog with a slot grid + (1..N); the user selects which shot he is about to capture, scans, the + frame lands in its slot; when 1..N are filled the payload is assembled, + parsed into `WillItem`s, validity-checked locally. + - **Post-capture flow (owner decision D6, amended)**: after capture + completes there is NO read-only preview. Instead a review-and-sign wizard + walks through every transaction one at a time showing **outputs + (address + amount), total outputs, total fees**, signs it (wallet + password asked once), and at the end **proposes exporting the signed + transactions** (to file and/or back via QR). +- **Audio-modem channel** (owner decision D7): when the Electrum + `audio_modem` plugin is enabled and available, the export dialog gains a + *"Send via Audio Modem…"* button and the import dialog a *"Receive via + Audio Modem…"* button, reusing the same transfer string (no QR framing). +- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs + (owner decision D1). No QML code in this feature. + +### Out of scope + +- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2). +- Merging imported wills into the live wallet state (existing Merge flows + stay unchanged). +- Broadcast of the reviewed transactions (user exports them; broadcasting + remains an explicit action elsewhere). +- CLI/cmdline parity for QR transfer. + +--- + +## 2. Owner decisions (locked) + +| # | Decision | +|---|----------| +| D1 | QML part = update `QML_PLAN.md` document only; implementation later. | +| D2 | Frames carry a small ASCII header (`BALQR1\|N\|i\|flags`) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. | +| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. | +| D4 | Compression (zlib+base64 over the whole payload) = checkbox in the export dialog, **default OFF**, advertised via a frame flag. | +| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. | +| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. | +| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. | + +--- + +## 3. Verified facts (research done on the local checkouts) + +All verified by reading source; references are `file:line`. + +| # | Fact | Where | +|---|------|-------| +| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` | +| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` | +| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` | +| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` | +| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` | +| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` | +| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) | +| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` | +| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` | +| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` | +| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` | +| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` | +| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` | +| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` | +| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib | +| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` | +| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` | +| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` | +| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` | +| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` | +| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` | +| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check | +| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 | + +--- + +## 4. Wire format specification + +### 4.1 Transfer string + +``` +transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid ) +``` + +- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for + partially-signed ones. Neither alphabet contains `\n` or `|`, so both are + safe delimiters. +- Ordering: ascending `tx.txid()` → deterministic output for identical input. +- If compression enabled (D4): + `transfer_string = base64_ascii( zlib_compress( transfer_string ) )`. + +### 4.2 Frame layout (one frame = content of ONE QR code) + +``` +BALQR1|||| +``` + +- Magic+version literal `BALQR1` (reject anything else with a clear message; + keeps the door open for a future `BALQR2`). +- `` N, `` i — integers, `1 ≤ i ≤ N`. +- ``: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed). +- ``: the i-th slice of `transfer_string`, exactly + `chunk_size` bytes each (last slice may be shorter). +- Header overhead ≈ 16–20 bytes → effective payload = `chunk_size − overhead`; + the chunker slices the transfer string so that **header+payload ≤ preset + size**. + +### 4.3 Size presets (D5) + +| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M | +|--------------|------------------------------|--------------------------| +| Small (low-res cameras) | 150 | ~v10 | +| Medium | 400 | ~v15 | +| Large | 900 | ~v22 | +| XL (high-res cameras) | 1800 | ~v40 | + +EC level fixed **M** for scan reliability (D5). Presets live in +`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them. + +### 4.4 Audio-modem channel (D7, F20-F23) + +- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames + (`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin + compresses internally; double compression wastes airtime). +- The existing core functions `encode_transfer(tx_strings, + compress=False)` + `decode_transfer(text, compressed=False)` are reused + unchanged; only the transport differs. +- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds + no setting of its own. + +--- + +## 5. New core module — `bal/core/qrtransfer.py` + +GUI-free (never imports Qt — house rule). Public API: + +```python +MAGIC = "BALQR" +VERSION = 1 +FLAG_COMPRESSED = "Z" +CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table + +def encode_transfer(tx_strings: list[str], compress: bool = False) -> str + """Join -> optional zlib+base64 -> return transfer_string.""" + +def split_frames(transfer_string: str, chunk_size: int) -> list[str] + """Slice into full frames 'BALQR1|N|i|flags|payload'. Raises + ValueError if chunk_size < MIN_CHUNK_SIZE.""" + +def parse_frame(frame: str) -> tuple[int, int, bool, str] + """-> (total, index, compressed, payload); ValueError on bad magic/ + version/arity/non-int fields.""" + +def assemble(frames: dict[int, str]) -> str + """Validate indices form exactly range(1..max_total) (taken from any + frame header), concatenate payloads in order, decode flags -> + transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError.""" + +def decode_transfer(transfer_string: str, compressed: bool) -> list[str] + """Inverse of encode_transfer -> list of tx strings.""" +``` + +Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`, +`InconsistentTotalError`. All docstrings/comments English; ruff-clean +(line-length 88, E501 ignored). + +--- + +## 6. Settings (Qt) + +1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add + + ```python + self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) + ``` + +2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844): + append row **13** — visible in BASIC and ADVANCED (do NOT wrap with + `_hide_if_basic`): + + - Label: `"QR Code Size"` + - `QComboBox` fed from `CHUNK_PRESETS`; item text e.g. + `"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged` + → `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from + `QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value + was customized). + - `HelpButton` text: explains trade-off (small QR = more shots but easier + to scan with poor cameras; large QR = fewer shots, needs good camera) + and that the size can also be changed inside the export dialog. + - Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)` + pattern (plugin.py:684). + +--- + +## 7. Qt export flow + +### 7.1 Entry point + +`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670): + +```python +export_menu.addAction(_("Via QR…"), self.export_will_valid_qr) +``` + +New `WillList.export_will_valid_qr()` mirrors `export_will_valid` +(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty → +`show_message(_("No valid will item to export"))`, else +`self.bal_window.export_will_via_qr(will=subset)`. + +### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near +`export_will`) + +- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]` + (F11). +- Mark exported items `EXPORTED` (parity with `export_json_file`, + window.py:1607-1609) — only when `will` came from the live list. +- Open `WillQrExportDialog(self, tx_strings)`. + +Shared helper used by both dialogs: + +```python +def get_audio_modem_plugin(self): # on BalWindow + """Return the loaded audio_modem plugin if enabled AND available + (amodem importable), else None. Never raises.""" + p = self.window.plugins.get("audio_modem") # F22 + return p if p is not None and p.is_available() else None +``` + +### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`) + +Layout: + +``` +[Size ▾ Small/Medium/Large/XL] [x Compress (zlib+base64)] +[ QR image ] ← BalQrImage (see below) +«i di N» [◀ Prev] [Next ▶] +[Save current QR as PNG…] [Send via Audio Modem…] [Close] +``` + +Behaviour: + +- On any control change: rebuild `split_frames(encode_transfer(...))`, + reset index to frame 1, refresh counter (owner requirement: "cambiare la + risoluzione"). +- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget` + (`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing + `qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting + via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines. +- Prev/Next wrap or disable at ends (disable chosen: clearer). +- PNG export optional convenience via existing + `getSaveFileName` + `QWidget.grab()` (same trick as + `qrcodewidget.py:110`). +- **Send via Audio Modem…** (D7): shown only when + `bal_window.get_audio_modem_plugin()` returns a usable instance (below); + otherwise hidden. Handler: re-encode the payload **plain** + (`encode_transfer(tx_strings, compress=False)`) and call the plugin's + `_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns + progress/cancellation (F20). Tooltip when hidden is unnecessary; instead, + if the plugin is enabled but `is_available()` is False, show an info + message pointing to `pip install amodem` + portaudio (F22). + +--- + +## 8. Qt import flow + review/sign wizard + +### 8.1 Entry point + +`lists.py` toolbar menu, next to Import/Merge (lines 670-671): + +```python +menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr()) +``` + +`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`. + +### 8.2 `WillQrImportDialog(BalDialog)` + +State: `self.frames: dict[int, str]`, `self.total: int | None`, +`self.target_index: int | None`. + +Layout: + +``` +«Captured k of N» [Scan ▶] [Reset] +[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target] +hint line («Select a slot, then scan» / «Scan the first QR») + [Receive via Audio Modem…] [Review & Sign ▶] [Close] +``` + +Behaviour: + +- **Scan** → `scan_qrcode_from_camera(parent=self, + config=self.bal_window.window.config, callback=self._on_scan)` + (F13). One-shot per press; dialog stays open between shots (simplest, + matches Electrum UX; no continuous mode). +- `_on_scan(success, error, data)`: + - failure → `show_error(error)` (covers missing zbar/camera too); + - `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`; + - first valid frame adopts `total` and materializes the slot grid; + - frame whose `total` ≠ adopted total → warn + offer Reset (user may have + restarted the export with another size); + - valid → `frames[index] = payload`; auto-advance `target_index` to the + lowest missing index; refresh grid + counter. +- Clicking an empty slot sets `target_index` (owner requirement: manual + shot selection); a filled slot click asks to overwrite. +- **Receive via Audio Modem…** (D7): shown only when + `bal_window.get_audio_modem_plugin()` returns a usable instance. Handler: + build a tiny adapter object exposing `setText(str)` that stores the text + and invokes the shared post-receive continuation, then call + `plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the + received string is treated as the **whole payload**: skip frames/slots + entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's + item-building step (WillItem construction + validity pass + wizard). + Errors from the modem surface through the plugin's own dialog; empty + result (user cancelled) is silently ignored. +- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`: + runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError` + surfaces as `show_error` and keeps the dialog open. +- Build items exactly like `merge_single_transaction` (F4): + `WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string + are collected and reported at the end (bad string ≠ fatal for the rest). +- Local validity pass (F5 recipe) on the resulting dict; items failing + `VALID` are dropped and listed in a warning. Set + `wi.set_status("IMPORTED", True)` on survivors (mirrors + `import_will_into_details`, window.py:1753-1754). +- Then `close()` and start the wizard (§8.3) with the valid subset. Empty + result → stop with a message. + +### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6) + +Constructed with `(bal_window, willitems: dict[str, WillItem])` — the +imported subset lives **outside** the live wallet state (external mode, +F3). + +Flow: + +1. **Password once**: `password = bal_window.get_wallet_password()` + (window.py:1088-1100). Returns `False` on cancel → abort wizard; `None` + means unencrypted wallet → proceed without password. +2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by + txid like export): + + ``` + Tx 2 of 5 — a1b2…c3d1 (short txid) + Locktime: 2033-04-05 Status: unsigned (0/1 sigs) + ┌ outputs ─────────────────────────────────┐ + │ bc1q…heir1 0,042 BTC │ + │ bc1q…willexec fee 0,00012 BTC │ + │ bc1q…change 0,00988 BTC │ + └───────────────────────────────────────────┘ + Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB) + [Sign & Next ▶] [Skip] [Cancel all] + ``` + - Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()` + style helpers already imported in the qt layer; value via + `bal_window.window.format_amount`). + - Totals: `output_value()` sum; fees via `input_value() - output_value()` + after resolving inputs with `Will.add_info_from_will(will, wid, wallet)` + (F10); `-1`/unknown handled like widgets.py:1319-1324 (F9). +3. **Sign & Next** → sign this single tx through a **refactored helper** + extracted from the loop body of `sign_transactions` + (window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept + byte-equivalent; batch method calls the helper per iteration so existing + behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as + today; then advance. +4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the + already-signed txs remain in the wizard's local dict (still exportable — + confirmation dialog warns about skipped ones). +5. **Summary page**: `signed X of Y`, skipped/failed lists, then: + + ``` + [Save signed file…] [Show QR…] [Close] + ``` + - *Save file* = existing JSON path: `export_meta_gui(window, + "will.json", writer)` writing `{wid: wi.to_dict()}` of the signed + subset (same serializer as `export_json_file`, window.py:1605). + - *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed + subset (the online machine can scan them straight into Merge). + - Nothing touches `self.willitems`/history (external-mode rule, F3). + +--- + +## 9. Checklist (execution order — tick here when resuming work) + +- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py` + (cases: round-trip plain/compressed; boundaries: len%size==0, size>len, + min-size guard; bad magic/version; missing middle frame; duplicate + overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs + qrcode capacities F15). Run: + `QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py` +- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16 + (ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run. +- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin` + helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`, + audio-modem send button (§7). +- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2), + incl. camera error paths, audio-modem receive button (local mirror of + `_recv`, `setText` sink replaced by a callback), plain-payload fast + path into the wizard. +- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog` + (§8.3). Regression-gate: full batch sign still green + (`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch + including new `tests/test_gui_qr_transfer.py`). +- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models += + `BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`), + Phase 3 += dedicated views `BalQrExportPage.qml` / + `BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the + "chunked streams deferred" note, rewrite R6 mitigation, add Android + caveat quoting F17 with file/paste fallback; README/HANDOFF sections; + CHANGELOG numbered entry 56 at END (house rule). +- [x] **P6** Release hygiene: `python3 build_zip.py` + + `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py + electrum.plugins.bal` + external-zip test; ruff (repo venv + `venv/bin/ruff`) no NEW violations; pyright false-positive policy per + AGENTS.md. Version bump only via `make-release.sh` (owner-driven). + Docs: document audio-modem as OPTIONAL channel — requires the + Electrum `audio_modem` plugin enabled plus `pip install amodem` + and libportaudio (not installed in the dev runtime env today, F22); + manual test matrix gains an audiomodem round-trip row (two machines, + default slowest bitrate) marked optional/skippable when hardware + unavailable. + +--- + +## 10. Risks & mitigations + +| Risk | Mitigation | +|------|------------| +| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible | +| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) | +| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) | +| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 | +| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage | +| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback | +| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency | +| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) | +| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads | +| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback | + +--- + +## 11. Findings log (append-only) + +- 2026-08-25: plan drafted after code exploration; owner answered D1-D6 + (D6 amended live from "preview dialog" to "review+sign wizard"). +- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip + guarantees) — both shaped §§4/7. +- 2026-08-25: owner requested an audio-modem transfer path → researched + `electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons + in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s + only contract is `parent.setText(blob)` (F21) → thin adapter object; and + BAL must not chunk/compress on this channel (F23). `amodem` is NOT in + the runtime env yet — feature is strictly optional. diff --git a/QML_PLAN.md b/QML_PLAN.md new file mode 100644 index 0000000..be00a56 --- /dev/null +++ b/QML_PLAN.md @@ -0,0 +1,379 @@ +# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support) + +> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is +> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will +> transactions from an online machine (desktop/another phone), sign them with +> the wallet keys held on it, and return the signed transactions — a classic +> air-gapped signer workflow. Online features (willexecutor contact, +> broadcast, build) are secondary on Android and belong mainly to the online +> machine. +> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic +> through the existing GUI-free `BalController`, exactly like `bal/cli/` +> already does. The PyQt6 desktop GUI remains untouched and primary. +> +> Status: DRAFT for owner review (rule R4 — no code until explicit OK). +> Chat language: Italian; this document is in English per rule R1. + +--- + +## 1. Verified facts (research done on the local checkouts) + +All items below were verified by reading source, not assumed. + +| # | Fact | Where | +|---|------|-------| +| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` | +| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` | +| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `//qml/.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` | +| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `/home/steal/devel/bal/electrum470/electrum/gui/{qml,common_qt}` | +| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 | +| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` | +| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` | +| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` | +| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` | +| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 | +| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` | + +--- + +## 2. Scope + +### In scope (MVP) + +Two usage **profiles** share one codebase: + +- **Offline signer profile** (Android, PRIORITY): works with no network. + 1. Import a will bundle exported by the online machine (existing JSON will + format, see F12) — via file share, paste, or QR. + 2. Review what is being signed (destinations, amounts, locktimes, fees). + 3. Sign internally (`wallet.sign_transaction` + password prompt). + Partial signatures are combined when the bundle is re-imported + (`combine_with_other_psbt`, already supported by `merge_will` logic). + 4. Export the signed bundle back to the online machine. +- **Online manager profile** (desktop QML, secondary): + - Will status overview (state, expiry/check-alive date, reminder info). + - Heirs list (view/add/edit/remove, addresses or URIs). + - Will-executor selection (list, enable/disable, fee display, refresh). + - Build will (simplified wizard reusing core validation). + - Sign in place (password) or hand off to an offline signer via bundles. + - Broadcast / push to will-executors; invalidate will; check-alive refresh. + - Basic settings mapped onto `will_settings`. + +The offline signer pages are built first and must function with the network +disabled (Electrum runs fine offline; willexecutor refresh simply degrades). + +Also in scope: + +- Android packaging: BAL bundled as internal plugin in the custom APK, + **enabled by default** (owner decision D3). +- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged. + +### Out of scope (explicitly deferred) + +- Full parity with the PyQt6 GUI (calendar widget, preview list editor, + advanced fee controls, themes). +- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP + builds keep working for desktop exactly as today, without `qml` UI). +- iOS, upstream-Electrum (spesmilo) compatibility. +- Lightning-related features (irrelevant to BAL). + +--- + +## 3. Architecture + +``` + ┌───────────────────────────────────────────┐ + │ bal/core │ + │ heirs, will, willexecutors, checkalive, │ + │ reminders, input_rules, plugin_base │ + └────────────┬──────────────────────────────┘ + │ (no Qt anywhere) + ┌─────────────────────┼──────────────────────┐ + ▼ ▼ ▼ + bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW + BalWindow etc. BalController qml_plugin.py (BalQmlPlugin) + (~9.3k lines) (headless flows) models.py (QObject VMs) + so.py (PluginQObject) + *.qml (views) + ▲ + wraps ONE BalController + per loaded wallet +``` + +Design rules: + +- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject + wrappers around one `BalController` instance per wallet. No business logic + in QML or in the wrappers beyond formatting. +- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`) + are registered by `bal/core/plugin_base.py` already; QML reads/writes them + through the controller, so a wallet moves between desktop/Android unchanged. +- **Threading.** Network operations (willexecutor fetch/push, broadcast) + run in worker threads exactly as the CLI does; results marshalled to the UI + thread via Qt signals on the wrapper objects. No blocking calls in slots. +- **One transfer format.** The airgap round trip reuses the existing JSON + will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's + `export_json_file`/`import` + `merge_will` flow already produces and + consumes — see F12). No new format is invented; export/merge logic gets a + single shared home usable by both frontends. +- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and + defensively; if absent (e.g., odd build), the plugin degrades to core-only + behavior instead of crashing the daemon. + +--- + +## 4. Work breakdown + +### Phase 0 — Verification spikes (no product code) + +| Task | Description | Exit criterion | +|------|-------------|----------------| +| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section | +| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash | +| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` | +| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file | + +Deliverable: short findings report appended to this document; go/no-go. + +### Phase 1 — Skeleton integration + +Files (all NEW unless noted): + +``` +bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py +bal/gui/qml/__init__.py package docstring +bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase) +bal/gui/qml/so.py class BalSignalObject(PluginQObject) +bal/manifest.json MODIFIED: available_for += ["qml"] +``` + +Details: + +- `qml_plugin.py`: + - `@hook init_qml(self, app)`: store app ref; create `so` parented to app; + for each already-loaded wallet call `_on_wallet_loaded(wallet)` + (mirrors labels' pattern, see F3). + - `@hook load_wallet(self, wallet)` — **single argument** (F9); creates the + per-wallet view-model bundle (Phase 2) keyed by `wallet`. + - `@hook unload_wallet(self, wallet)`: drop controllers, close windows. +- `so.py`: `BalSignalObject(PluginQObject)` exposing: + - `loader` property returning `"BalMain.qml"` (drives F3 auto-create); + - signals: `walletChanged`, `willStateChanged`, `heirsChanged`, + `willexecutorsChanged`, `busyChanged`; + - slots called from QML: open/close window, refresh willexecutors, + check-alive now, build/sign/broadcast/invalidate commands. +- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched + (Electrum filters per running GUI, verified F2). + +Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created +(log line from `onPluginLoaded`), no functional UI yet. + +### Phase 2 — View-models (QObject layer) + +New file `bal/gui/qml/models.py`: + +- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only + properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`, + `sigsHave/sigsRequired` per tx) + notification signals; forwards actions to + controller methods (`build_will`, `sign_transactions`, `broadcast_will`, + `invalidate_will_headless`, `check_alive`... — names per controller). + - **Airgap methods (priority):** `export_will_bundle()` and + `import_will_bundle(json_text)` returning summary of what changed. These + are small ports of the Qt GUI's `export_json_file` (window.py:1605) and + `merge_will` (window.py:1620) semantics. Preferred implementation: move + the logic into shared helpers (controller level or `bal/core/will.py` + static functions) and make the Qt GUI call the same helpers, so the two + frontends cannot diverge; regression-covered by existing core tests plus + new round-trip tests. + - Offline profile detection: expose an `isOffline` property derived from + `wallet.network is None` / config, so QML can hide online-only pages. +- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`, + `valid`; edit methods delegate to `Heirs` helpers through controller. +- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`, + `sigsHave`, `sigsRequired`, `isComplete`. +- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`, + `fee`, `valid`; toggle + async refresh. +- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer + planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt + plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL / + pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets; + re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR + rendering inside the model (QML paints it). +- All list mutations happen on the controller state then `beginResetModel/ + endResetModel` (datasets are small; simplicity over incremental updates). + +Exit criterion: pytest-driven model tests pass offscreen (create models over a +regtest/testnet wallet fixture, assert roles after mutations). + +### Phase 3 — QML views + +New directory `bal/gui/qml/components/`: + +``` +BalMain.qml top-level Window; stack of pages below +BalSignPage.qml PRIORITY (offline signer): import bundle + (paste / file / QR), review summary of each tx, + password-sign, export signed bundle back +BalQrExportPage.qml QR export view: drives BalQrTransferModel, one + frame at a time (Prev/Next, progress i/N, chunk + preset selector), mirror of desktop Qt dialog +BalQrImportPage.qml QR import view: slot grid (1..N), camera via + Electrum `QRScan` reuse, manual paste fallback, + then jump into BalSignPage review/sign +BalStatusPage.qml will state, expiry countdown, check-alive button + (works offline with last-known data) +BalHeirsPage.qml ListView + add/edit dialog [online profile] +BalExecutorsPage.qml ListView with switches + refresh [online profile] +BalBuildPage.qml simplified build form (threshold selector, fees, + executor pick) → runs controller.build_will + [online profile] +BalSettingsPage.qml maps onto will_settings subset +controls/BalButton.qml, BalField.qml minimal styled primitives +qmldir module registration +``` + +Notes: + +- **BalSignPage review screen is mandatory**: before signing, the user must + see per-transaction heir address, amount, locktime (date), and fee — this + device is the security boundary, so no "blind signing". +- QR transfer: a full will bundle may exceed one QR's capacity when there are + many heirs/transactions. The desktop Qt plugin now resolves this with + chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets + 150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same + scheduler via a thin model. Order of preference stays (1) share/save file + + paste text; (2) chunked QR streams through `BalQrTransferModel`. +- Styling minimal, follow existing QML components' look (reuse + `controls/` from Electrum where importable — prefer copying tiny primitives + to avoid coupling to upstream churn; decide during implementation). +- Every string wrapped with `electrum.i18n._`. + +Exit criterion: full manual walkthrough on desktop QML GUI (offscreen + +interactive) performing BOTH: (a) online profile — configure heirs → select +executors → build → sign → broadcast → invalidate; and (b) offline signer +profile — export unsigned bundle from an online wallet, import into a second +offline wallet instance, sign, re-export, import signed bundle back into the +first wallet and verify signatures combined/status COMPLETE. Walkthrough (b) +must pass with networking disabled. + +### Phase 4 — Android integration + +- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to + `contrib/android/buildozer_qml.spec` in the Electrum fork. +- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)** + and default-enable BAL in the APK build (D3: enabled by default). +- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1 + approved). Fallback if T4 picks auto-open: window opens on wallet load. +- Rebuild APK; smoke-test on device/emulator: + install → BAL already enabled → open wallet → full MVP walkthrough, + including airplane-mode signing round trip (file/QR transfer between an + online desktop and the offline device — for emulator testing, "offline" = + network disabled via settings). +- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators — + already house style), background network on mobile (willexecutor timeouts), + APK size impact (bal is small; icons only), share-intent/file access + permissions for bundle import/export. + +Exit criterion: signed test APK passes the same walkthrough as Phase 3. + +### Phase 5 — Tests & CI hygiene + +- New tests following repo conventions (`def test_*` + `__main__` block): + - `tests/test_qml_models.py` — models over fake/controller-backed wallet + (offscreen, no network). + - `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle + from wallet A → import into wallet B (same seed, offline) → sign → + export signed → merge back into A; assert COMPLETE status and signature + counts. Must pass with no network. + - `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed + QML app object; `so` wiring correct; load/unload wallet lifecycle. + - Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3 + tests/smoke_test.py electrum.plugins.bal` still green (qt path intact). +- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW + violations) before any delivery ZIP. +- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml + offscreen, Android APK] × [4.7.2, 4.8.0] where applicable. + +### Phase 6 — Release plumbing & docs + +- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included + in deterministic zip (harmless on desktop; enables future extraction-based + loading). +- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status, + limitations, and how to enable (`-g qml` / APK toggle). +- `CHANGELOG.md`: numbered entry at END per house rules. +- Version bump + release handled by `make-release.sh` as usual (owner-driven). + +--- + +## 5. Risks & mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed | +| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop | +| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting | +| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI | +| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable | +| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 | +| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test | +| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` | +| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) | +| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework | + +--- + +## 6. Questions for the owner — ANSWERED (2026-08-25) + +1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's + `main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle. +2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.** + The phone holds the keys and acts as air-gapped signer: import unsigned + bundle → review → sign → export signed bundle back to the online machine. + The sign/import/export page is therefore the top priority of Phase 3, and + the round-trip test is the top priority of Phase 5. +3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK** + (toggle still available to disable). +4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get + back later. Not blocking: see risk R10 mitigation. + +--- + +## 7. Effort estimate + +| Phase | Rough size | +|-------|-----------| +| 0 spikes | ~half day (mostly reading + one throwaway branch) | +| 1 skeleton | ~300 lines Python | +| 2 models (incl. shared export/merge helpers) | ~600–800 lines Python | +| 3 views (offline signer page first) | ~900–1300 lines QML | +| 4 android | fork-side patches + build iteration (device-dependent) | +| 5 tests | ~500 lines | +| 6 release/docs | small | + +Overall: comparable to a medium feature, dominated by Phase 3 UI polish and +Phase 4 device iteration. + +--- + +## 8. Findings log (append-only) + +- **F12** — The airgap round trip already exists in the Qt GUI layer and can + be ported almost verbatim: `export_json_file()` (window.py:1605) exports + all will items as `{wid: WillItem.to_dict()}` JSON (marking them + `EXPORTED`); the import side goes through `merge_will()` + (window.py:1620), which carries operational statuses, combines partial + signatures via `tx.combine_with_other_psbt()` when txids match, substitutes + the tx otherwise, and recomputes validity locally without network. + Conclusion: no new transfer format is needed; the plan is to give this + logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML + all use one implementation. + +### Decisions + +- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by + the owner. +- **D2** — Android = offline signing device; sign/import/export flow has + top priority. +- **D3** — BAL enabled by default in the custom APK. +- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10). diff --git a/README.md b/README.md index ca3f196..c9bb548 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ bal/ the installable Electrum plugin package │ ├── willexecutors.py │ ├── checkalive.py │ ├── reminders.py +│ ├── qrtransfer.py QR will-transfer wire format / chunk scheduler │ └── input_rules.py ├── cli/ headless command-line layer (no Qt) │ ├── commands.py bal_* daemon commands (@plugin_command) @@ -87,6 +88,17 @@ Copy the `bal/` directory into your Electrum installation's `electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json` exists, then enable it from **Tools → Plugins**. +## Transfer a will with QR codes (or audio) + +From the will list (**Export → QR Codes**) a will can be exported as a +sequence of QR codes and imported on another device (**Import via QR**). The +export offers All / Valid / Valid-NC filters plus a QR size preset +(150–1800 bytes/frame); the import flow reviews and sign each transaction +one at a time, then proposes exporting the signed transactions. When +Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` + +PortAudio) Send/Receive audio buttons complement the QR channel. See +[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the full wire-format spec. + ## Command-line / headless usage BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 188e73e..c0fbe55 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -279,6 +279,13 @@ class BalPlugin(BasePlugin): # stay display-only outside the wizard unless the user opts in. self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False) + # QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used + # per QR frame when exporting/importing a will through the QR channel. + # The settings dialog offers the 4 standard presets of + # bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget. + # Default 150 (small QR, low-resolution cameras). + self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150) + # NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the # exported .ics calendar should contain. Each reminder becomes its own # VEVENT (its own date in the calendar). The dates are spread uniformly diff --git a/bal/core/qrtransfer.py b/bal/core/qrtransfer.py new file mode 100644 index 0000000..e131203 --- /dev/null +++ b/bal/core/qrtransfer.py @@ -0,0 +1,212 @@ +""" +bal.core.qrtransfer +=================== + +GUI-free helpers for moving BAL will data between devices via QR codes or +the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``). + +Scope +----- +* converts will transactions into a compact ``transfer_string`` + (newline-joined serialized transactions, optionally zlib + base64 + compressed); +* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for + multi-QR export, and reassembles/validates them on import. + +The audio-modem channel deliberately bypasses the framing helpers here +(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and +carries the whole transfer string in a single blob, so callers only use +:func:`encode_transfer` / :func:`decode_transfer`. + +This module never imports Qt or any Electrum GUI code (house rule). +""" + +from __future__ import annotations + +import base64 +import zlib + +MAGIC = "BALQR" +VERSION = 1 +FLAG_COMPRESSED = "Z" + +# 4 standard presets (label, payload budget in bytes per QR). Ordered from +# low-resolution cameras to high-resolution cameras (owner decision D5). +CHUNK_PRESETS = ( + ("Small - ~150 bytes/QR (low-res cameras)", 150), + ("Medium - ~400 bytes/QR", 400), + ("Large - ~900 bytes/QR", 900), + ("XL - ~1800 bytes/QR (high-res cameras)", 1800), +) + +# Smallest allowed payload budget per frame, below which the frame header +# could consume the whole budget. +MIN_CHUNK_SIZE = 40 + +_FRAME_MAGIC = MAGIC + str(VERSION) + + +class QrTransferError(ValueError): + """Base error for will QR / audio transfer processing.""" + + +class MissingFramesError(QrTransferError): + """Some frame indices of a multi-QR transfer are missing.""" + + def __init__(self, missing): + self.missing = list(missing) + super().__init__("Missing QR frames: {}".format(self.missing)) + + +class InconsistentTotalError(QrTransferError): + """Frames disagree about the advertised frame total.""" + + +def encode_transfer(tx_strings, compress=False): + """Join serialized transaction strings into a transfer string. + + ``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so + the whole bundle shrinks before being printed/scanned. The optional flags + of the frame header let the importer reverse this automatically. + """ + return __compress("\n".join(tx_strings), enabled=compress) + + +def decode_transfer(transfer_string, compressed): + """Inverse of :func:`encode_transfer`. + + Returns the list of serialized transaction strings; empty frames are + dropped so a trailing newline (or an empty payload) cannot produce an + empty trailing element. + """ + text = __decompress(transfer_string, enabled=compressed) + return [part for part in text.split("\n") if part] + + +def split_frames(transfer_string, chunk_size, compressed=False): + """Split ``transfer_string`` into full ``BALQR`` frames. + + Every returned frame is at most ``chunk_size`` characters long (header + included). ``compressed`` propagates the ``Z`` flag into every frame so + the importer knows how to reverse the encoding. + + Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold + the header plus any payload. + """ + flags = FLAG_COMPRESSED if compressed else "" + total = __compute_total(len(transfer_string), chunk_size, flags) + frames = [] + pos = 0 + length = len(transfer_string) + for index in range(1, total + 1): + overhead = len(__frame_header(total, index, flags)) + budget = chunk_size - overhead + end = min(pos + budget, length) + frames.append(__build_frame(total, index, flags, transfer_string[pos:end])) + pos = end + if pos >= length: + break + if pos < length: + # __compute_total guarantees this cannot happen; keep a safety net. + raise QrTransferError("internal error: frames did not cover the transfer string") + return frames + + +def parse_frame(frame): + """Parse a single frame. + + Returns ``(total, index, compressed: bool, payload: str)``. Raises + :class:`QrTransferError` on malformed input (bad magic/version, wrong + arity, non-integer or out-of-range frame numbers, unknown flags). + """ + parts = frame.split("|", maxsplit=4) + if len(parts) != 5: + raise QrTransferError("Not a BAL will QR (bad frame structure)") + magic_seen, total_s, index_s, flags, payload = parts + if magic_seen != _FRAME_MAGIC: + raise QrTransferError("Not a BAL will QR (unknown magic/version)") + try: + total = int(total_s) + index = int(index_s) + except ValueError as e: + raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e + if total < 1 or not 1 <= index <= total: + raise QrTransferError("Not a BAL will QR (frame numbering out of range)") + if flags not in ("", FLAG_COMPRESSED): + raise QrTransferError("Not a BAL will QR (unknown flags)") + return total, index, flags == FLAG_COMPRESSED, payload + + +def assemble(frames, total): + """Concatenate frame payloads back into a transfer string. + + ``frames`` maps 1-based index -> payload. Every index ``1..total`` must + be present (else :class:`MissingFramesError`) and no index may exceed + ``total`` (else :class:`InconsistentTotalError`). + """ + if total < 1: + raise QrTransferError("invalid frame total") + missing = [index for index in range(1, total + 1) if index not in frames] + if missing: + raise MissingFramesError(missing) + extra = [index for index in frames if index > total] + if extra: + raise InconsistentTotalError() + return "".join(frames[index] for index in range(1, total + 1)) + + +def preset_index_for_chunk_size(chunk_size): + """Return the :data:`CHUNK_PRESETS` index whose budget best matches a size.""" + best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1]) + for index, (_label, budget) in enumerate(CHUNK_PRESETS): + diff = abs(chunk_size - budget) + if diff < best_diff: + best, best_diff = index, diff + return best + + +# --------------------------------------------------------------------------- # +# Internals +# --------------------------------------------------------------------------- # + +def __compress(text, *, enabled): + if not enabled: + return text + return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii") + + +def __decompress(text, *, enabled): + if not enabled: + return text + return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8") + + +def __frame_header(total, index, flags): + return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags) + + +def __build_frame(total, index, flags, payload): + return __frame_header(total, index, flags) + payload + + +def __compute_total(transfer_len, chunk_size, flags): + """Smallest frame count whose budget covers the whole transfer string. + + The budget shrinks as ``total`` gains digits (wider header), so the count + is recomputed iteratively until it converges. + """ + if chunk_size < MIN_CHUNK_SIZE: + raise QrTransferError( + "chunk size too small to hold a BAL QR frame: {}".format(chunk_size) + ) + total = 1 + while True: + overhead = len(__frame_header(total, total, flags)) + budget = chunk_size - overhead + if budget <= 0: + raise QrTransferError( + "chunk size too small for the BAL QR frame header: {}".format(chunk_size) + ) + if transfer_len <= budget * total: + return total + total += 1 \ No newline at end of file diff --git a/bal/core/will.py b/bal/core/will.py index 4122b26..bdd7cfc 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -1338,7 +1338,7 @@ class WillItem(Logger): self.tx = Will.get_tx_from_any(w["tx"]) self.heirs = w.get("heirs", None) self.we = w.get("willexecutor", None) - self.status = w.get("status", None) + self.status = w.get("status") or "" self.description = w.get("description", None) self.time = w.get("time", None) self.change = w.get("change", None) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 3ce13ca..a680482 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -28,6 +28,7 @@ from functools import partial from typing import Any, Callable, Mapping, Optional, Union from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN +from electrum.gui.common_qt.util import draw_qr from electrum.gui.qt.amountedit import BTCAmountEdit from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton from electrum.gui.qt.my_treeview import MyTreeView @@ -42,6 +43,7 @@ from electrum.gui.qt.util import ( MessageBoxMixin, OkButton, TaskThread, + WaitingDialog, WindowModalDialog, char_width_in_lineedit, getOpenFileName, diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 23e514d..73bca32 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -11,16 +11,35 @@ All modal/non-modal dialogs of the plugin. * BalBuildWillDialog - the central build/sign/push/broadcast flow. * WillDetailDialog - shows the full will tree for one wallet. * WillExecutorDialog - manage the list of will-executor servers. + * WillQrExportDialog - export the will as a sequence of QR codes. + * WillQrImportDialog - capture/assemble a will from QR shots (or + audio) and send it to the review+sign wizard. + * WillTxReviewSignDialog - per-transaction review/sign wizard for the + imported will (external copy, never touches the live will). To keep the dialogs verbatim while avoiding import cycles with the list views, the few list classes they reference are imported lazily inside the methods that use them (see ``lists`` imports below). """ +import io +import zlib + from typing import TYPE_CHECKING -from ...core.checkalive import CheckAliveError +from ...core.checkalive import CheckAliveError, resolve_date_to_check from ...core.reminders import build_ics_reminders +from ...core.qrtransfer import ( + CHUNK_PRESETS, + MissingFramesError, + QrTransferError, + assemble, + decode_transfer, + encode_transfer, + parse_frame, + preset_index_for_chunk_size, + split_frames, +) from .calendar import BalCalendarButton from .common import ( _, @@ -42,13 +61,18 @@ from .common import ( NoHeirsException, NoWillExecutorNotPresent, NotCompleteWillException, + QCheckBox, QComboBox, QDialog, + QGridLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QScrollArea, QSizePolicy, + QSpinBox, + QStackedWidget, QTimer, QVBoxLayout, QWidget, @@ -57,16 +81,21 @@ from .common import ( TxBroadcastError, TxFeesChangedException, Util, + WaitingDialog, Will, WillExecutorFeeTooHighException, WillExecutorNotPresent, WillExpiredException, + WillItem, WillPostponedException, WillexecutorChangeException, Willexecutors, bring_to_front, decimal_point_to_base_unit_name, + draw_qr, + export_meta_gui, import_meta_gui, + log_error, partial, pyqtSignal, read_QIcon_from_bytes, @@ -76,6 +105,7 @@ from .common import ( stop_thread, time, top_level_of, + write_json_file, ) from .widgets import ( WillSettingsWidget, @@ -2363,3 +2393,811 @@ class HeirsDialog(BalDialog, MessageBoxMixin): def closeEvent(self, event): event.accept() + +# --------------------------------------------------------------------------- # +# QR / audio will transfer +# --------------------------------------------------------------------------- # + +class BalQrImage(QWidget): + """A widget that renders one QR code, scaled to its own size. + + Electrum's own ``QRCodeWidget`` hard-codes the LOW error-correction level, + which is fine for a one-shot payload but risky for long multi-frame will + transfers. This widget renders a fresh code on every paint with MEDIUM + correction using Electrum's :func:`draw_qr` paint helper. + """ + + def __init__(self, text="", parent=None): + QWidget.__init__(self, parent) + self.text = text + self.setMinimumSize(240, 240) + self.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + + def set_text(self, text): + self.text = text + self.update() + + def paintEvent(self, event): + # Imported lazily: qrcode is shipped with Electrum but it is not a Qt + # widget, so keeping it out of the hub import keeps dialogs importable + # even when qrcode itself is missing at import time. + import qrcode + + qr = qrcode.QRCode( + error_correction=qrcode.ERROR_CORRECT_M, border=2 + ) + qr.add_data(self.text) + qr.make(fit=True) + draw_qr( + qr=qr, paint_device=self, is_enabled=True, min_boxsize=2 + ) + QWidget.paintEvent(self, event) + + +class WillQrExportDialog(BalDialog): + """Export a will as a sequence of QR codes, one per screen. + + The chosen transactions are serialized with the wire format of + :mod:`bal.core.qrtransfer` and split into frames of the configured chunk + size. The user walks the frames with Prev/Next arrows; the chunk size can + be changed live from the standard presets. An "Audio" send button is shown + when Electrum's ``audio_modem`` plugin is available (raw newline-joined + transactions, no BAL framing, since the audio transport zlib-compresses + internally). + """ + + def __init__(self, bal_window, will=None, bal_plugin=None): + BalDialog.__init__( + self, bal_window.window, bal_plugin, _("Export will via QR codes") + ) + self.bal_window = bal_window + try: + chunk = int(bal_window.bal_plugin.QR_CHUNK_SIZE.get()) + except Exception: + chunk = CHUNK_PRESETS[0][1] + self.chunk_size = chunk + self._source = will if will is not None else bal_window.willitems + # Export filters: which will items the QR transfer includes. Mirrors + # the All / Valid / Valid-NC choices of the "Export" file menu. + self._filters = [ + (_("All"), lambda wi: True), + (_("Valid"), lambda wi: wi.get_status("VALID")), + ( + _("Valid NC"), + lambda wi: wi.get_status("VALID") and not wi.get_status("COMPLETE"), + ), + ] + self._filter_index = 0 + self.auto_timer = QTimer(self) + self.auto_timer.timeout.connect(self._auto_step) + self._build_transfer(self._filtered_willitems()) + if not self.tx_strings: + self.show_message(_("No will transaction to export.")) + self.close() + return + self.index = 0 + vbox = QVBoxLayout(self) + + self.intro_label = QLabel() + self._update_intro() + vbox.addWidget(self.intro_label) + + filter_row = QHBoxLayout() + filter_row.addWidget(QLabel(_("Export:"))) + self.filter_combo = QComboBox() + self.filter_combo.addItems([label for label, _fn in self._filters]) + self.filter_combo.currentIndexChanged.connect(self._on_filter_change) + filter_row.addWidget(self.filter_combo) + filter_row.addStretch(1) + vbox.addLayout(filter_row) + + self.qr_view = BalQrImage(parent=self) + self.qr_view.set_text(self.frames[self.index]) + vbox.addWidget(self.qr_view) + + self.progress_label = QLabel() + vbox.addWidget(self.progress_label) + + nav = QHBoxLayout() + self.prev_btn = QPushButton(_("Previous")) + self.prev_btn.clicked.connect(self._prev) + nav.addWidget(self.prev_btn) + self.next_btn = QPushButton(_("Next")) + self.next_btn.clicked.connect(self._next) + nav.addWidget(self.next_btn) + nav.addStretch(1) + + if bal_window.get_audio_modem_plugin() is not None: + audio_btn = QPushButton(_("Audio…")) + audio_btn.setToolTip( + _("Send the export over your speaker (Audio MODEM plugin).") + ) + audio_btn.clicked.connect(self._audio_send) + nav.addWidget(audio_btn) + + vbox.addLayout(nav) + + auto_row = QHBoxLayout() + self.auto_btn = QPushButton(_("Auto")) + self.auto_btn.setToolTip( + _("Automatically advance through the QR codes.") + ) + self.auto_btn.clicked.connect(self._toggle_auto) + auto_row.addWidget(self.auto_btn) + auto_row.addWidget(QLabel(_("QR codes per second:"))) + self.fps_spin = QSpinBox() + self.fps_spin.setRange(1, 10) + self.fps_spin.setValue(1) + self.fps_spin.setSuffix(_(" /s")) + auto_row.addWidget(self.fps_spin) + self.loop_check = QCheckBox(_("Loop")) + self.loop_check.setToolTip( + _("When the last QR code is reached, keep cycling from the " + "first one instead of stopping.") + ) + auto_row.addWidget(self.loop_check) + auto_row.addStretch(1) + vbox.addLayout(auto_row) + + size_row = QHBoxLayout() + size_row.addWidget(QLabel(_("QR code size:"))) + self.size_combo = QComboBox() + self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS]) + self.size_combo.setCurrentIndex( + preset_index_for_chunk_size(self.chunk_size) + ) + self.size_combo.currentIndexChanged.connect(self._on_chunk_change) + size_row.addWidget(self.size_combo) + size_row.addStretch(1) + vbox.addLayout(size_row) + + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + + self._render() + + def _build_transfer(self, willitems): + """Serialize the chosen transactions into frame-ready state.""" + items = sorted(willitems.values(), key=lambda wi: str(wi.tx.txid())) + self.tx_strings = [str(wi.tx) for wi in items] + self.audio_payload = "\n".join(self.tx_strings) + self.transfer = encode_transfer(self.tx_strings, compress=False) + self._refresh_frames() + self.total_frames = len(self.frames) + + def _refresh_frames(self): + self.frames = split_frames( + self.transfer, self.chunk_size, compressed=False + ) + self.index = 0 + + def _on_chunk_change(self, index): + self._stop_auto() + self.chunk_size = CHUNK_PRESETS[index][1] + self._refresh_frames() + self._render() + + def _toggle_auto(self): + """Start/stop the automatic QR slideshow.""" + if self.auto_timer.isActive(): + self._stop_auto() + return + if len(self.frames) <= 1: + return + fps = self.fps_spin.value() + if fps <= 0: + return + self.auto_btn.setText(_("Stop")) + self.auto_timer.start(int(1000 / fps)) + + def _stop_auto(self): + if self.auto_timer.isActive(): + self.auto_timer.stop() + self.auto_btn.setText(_("Auto")) + + def _auto_step(self): + if self.index >= len(self.frames) - 1: + # Reach the last code: loop back or stop the slideshow. + if self.loop_check.isChecked(): + self.index = 0 + self._render() + return + self._stop_auto() + return + self._next() + + def _filtered_willitems(self): + """The will items selected by the current export filter.""" + _label, fn = self._filters[self._filter_index] + return {wid: wi for wid, wi in self._source.items() if fn(wi)} + + def _update_intro(self): + self.intro_label.setText( + _( + "Scan the QR codes below, in order, with the will-opening " + "device.\nFrame 1 of {} carries the total number of codes." + ).format(self.total_frames) + ) + + def _on_filter_change(self, index): + previous = self._filter_index + self._filter_index = index + self._stop_auto() + if not self._filtered_willitems(): + # The selection is empty under the new filter: revert and inform. + self.filter_combo.blockSignals(True) + self.filter_combo.setCurrentIndex(previous) + self.filter_combo.blockSignals(False) + self._filter_index = previous + self.show_message(_("No will transaction matches the selected filter.")) + return + self._build_transfer(self._filtered_willitems()) + self._update_intro() + self._render() + + def _prev(self): + if self.index > 0: + self.index -= 1 + self._render() + + def _next(self): + if self.index < len(self.frames) - 1: + self.index += 1 + self._render() + + def _render(self): + self.qr_view.set_text(self.frames[self.index]) + self.progress_label.setText( + _("Frame {} of {}").format(self.index + 1, len(self.frames)) + ) + self.prev_btn.setEnabled(self.index > 0) + self.next_btn.setEnabled(self.index < len(self.frames) - 1) + + def _audio_send(self): + try: + self.bal_window._audio_send_payload(self.audio_payload) + except Exception as e: + log_error(e, self) + self.show_error(str(e)) + + +class WillQrImportDialog(BalDialog): + """Import a will by scanning its QR codes (or receiving it by audio). + + Frames are captured one by one from the camera (or typed manually). The + first frame fixes the total frame count and the transfer compression flag; + the slot grid shows which frames are still missing. When every frame is + present the "Review and Sign" button assembles the transfer, decodes it + into transactions and hands them to :class:`WillTxReviewSignDialog`. The + audio path is one-shot (no BAL framing) and jumps straight to the wizard. + All work happens on a local copy; the live will is never touched. + """ + + def __init__(self, bal_window, bal_plugin=None): + BalDialog.__init__( + self, bal_window.window, bal_plugin, _("Import will via QR codes") + ) + self.bal_window = bal_window + self.frames = {} + self.total = 0 + self.compressed = False + self._scanning = False + self.slot_widgets = {} + + vbox = QVBoxLayout(self) + intro = QLabel( + _( + "Scan the QR codes printed by the will-opening device, one " + "shot at a time.\nDuplicates are ignored; the first frame " + "sets the total number of codes." + ) + ) + intro.setWordWrap(True) + vbox.addWidget(intro) + + self.status_label = QLabel(_("Waiting for the first frame…")) + vbox.addWidget(self.status_label) + + # Slot grid inside a scroll area (a large will can need many frames). + self.slot_widget = QWidget() + self.slot_grid = QGridLayout(self.slot_widget) + self.slot_grid.setSpacing(4) + self.slot_area = QScrollArea() + self.slot_area.setWidget(self.slot_widget) + self.slot_area.setWidgetResizable(True) + self.slot_area.setMaximumHeight(180) + self.slot_area.setVisible(False) + vbox.addWidget(self.slot_area) + + manual = QHBoxLayout() + self.manual_edit = QLineEdit() + self.manual_edit.setPlaceholderText( + _("…or paste/type the frame text here") + ) + self.manual_edit.returnPressed.connect(self._add_from_manual) + manual.addWidget(self.manual_edit) + manual_btn = QPushButton(_("Add frame")) + manual_btn.clicked.connect(self._add_from_manual) + manual.addWidget(manual_btn) + vbox.addLayout(manual) + + buttons = QHBoxLayout() + self.scan_btn = QPushButton(_("Scan QR with camera")) + self.scan_btn.clicked.connect(self._scan_camera) + buttons.addWidget(self.scan_btn) + if bal_window.get_audio_modem_plugin() is not None: + audio_btn = QPushButton(_("Receive by audio…")) + audio_btn.clicked.connect(self._audio_receive) + buttons.addWidget(audio_btn) + self.reset_btn = QPushButton(_("Reset")) + self.reset_btn.clicked.connect(self._reset_all) + buttons.addWidget(self.reset_btn) + buttons.addStretch(1) + vbox.addLayout(buttons) + + bottom = QHBoxLayout() + self.review_btn = QPushButton(_("Review and Sign…")) + self.review_btn.setEnabled(False) + self.review_btn.clicked.connect(self._review_and_sign) + bottom.addWidget(self.review_btn) + bottom.addStretch(1) + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + bottom.addWidget(close_btn) + vbox.addLayout(bottom) + + # -- frame handling ------------------------------------------------------- + + def _add_from_manual(self): + text = self.manual_edit.text().strip() + if text: + self.manual_edit.clear() + self._add_frame(text) + + def _add_frame(self, frame_text): + try: + total, index, compressed, payload = parse_frame(frame_text) + except QrTransferError as e: + self.show_error(str(e)) + return + if self.total and total != self.total: + # A different total means a different transfer: wipe and restart. + self._reset_all() + self.show_warning( + _( + "The scanned code belongs to a different transfer ({} " + "frames). The import was reset; scan the first code again." + ).format(total) + ) + return + if not self.total: + self.total = total + self.compressed = compressed + self._init_slots() + self.frames[index] = payload + self._update_slots() + self._update_status() + + def _init_slots(self): + while self.slot_grid.count(): + item = self.slot_grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + self.slot_grid.removeItem(item) + self.slot_widgets = {} + for index in range(1, self.total + 1): + b = QPushButton(str(index)) + b.setEnabled(False) + row = (index - 1) // 8 + col = (index - 1) % 8 + self.slot_grid.addWidget(b, row, col) + self.slot_widgets[index] = b + self.slot_area.setVisible(True) + + def _update_slots(self): + for index, b in self.slot_widgets.items(): + present = index in self.frames + b.setStyleSheet( + "QPushButton{background-color:#90ee90;}" if present else "" + ) + + def _update_status(self): + have = len(self.frames) + if have >= self.total: + self.status_label.setText(_("All {} frames stored.").format(self.total)) + self.review_btn.setEnabled(True) + else: + self.status_label.setText( + _("Stored {} of {} frames.").format(have, self.total) + ) + self.review_btn.setEnabled(False) + + def _reset_all(self): + self.frames = {} + self.total = 0 + self.compressed = False + if self.slot_widgets: + for b in self.slot_widgets.values(): + b.deleteLater() + self.slot_widgets = {} + self.slot_area.setVisible(False) + self.review_btn.setEnabled(False) + self.status_label.setText(_("Waiting for the first frame…")) + + # -- capture -------------------------------------------------------------- + + def _scan_camera(self): + if self._scanning: + return + from electrum.gui.qt.qrreader import scan_qrcode_from_camera + + self._scanning = True + self.scan_btn.setEnabled(False) + + def callback(success, error, data): + self._scanning = False + self.scan_btn.setEnabled(True) + if success and data: + self._add_frame(data) + elif not success and error: + self.show_error(str(error)) + + try: + scan_qrcode_from_camera( + parent=self, + config=self.bal_window.window.config, + callback=callback, + ) + except Exception as e: + self._scanning = False + self.scan_btn.setEnabled(True) + self.show_error(str(e)) + + def _audio_receive(self): + """Receive a transfer over the audio_modem plugin (raw tx list). + + The audio transport compresses internally and carries no BAL framing, + so this mirrors the plugin's own ``_recv`` (which hard-wires a + ``setText`` sink) but delivers the decoded text through a callback. + """ + plugin = self.bal_window.get_audio_modem_plugin() + if plugin is None: + self.show_error(_("Audio MODEM plugin is not available.")) + return + try: + import amodem # noqa: F401 # type: ignore (guaranteed by is_available) + except Exception as e: + self.show_error(str(e)) + return + + def receiver_thread(): + with plugin._audio_interface() as interface: + src = interface.recorder() + dst = io.BytesIO() + amodem.main.recv(config=plugin.modem_config, src=src, dst=dst) + return dst.getvalue() + + def on_success(blob): + if not blob: + return + try: + text = zlib.decompress(blob).decode("ascii") + except Exception as e: + self.show_error(str(e)) + return + tx_strings = [ + part for part in text.split("\n") if part and part.strip() + ] + if not tx_strings: + self.show_error(_("No transaction data received.")) + return + self._finish_import(tx_strings) + + kbps = plugin.modem_config.modem_bps / 1e3 + WaitingDialog( + self, + _("Waiting for audio ({:.1f} kbps)…").format(kbps), + receiver_thread, + on_success, + ) + + # -- finish --------------------------------------------------------------- + + def _review_and_sign(self): + try: + transfer = assemble(self.frames, self.total) + tx_strings = decode_transfer(transfer, self.compressed) + except (MissingFramesError, QrTransferError) as e: + self.show_error(str(e)) + return + if not tx_strings: + self.show_error(_("The transferred will contains no transactions.")) + return + self._finish_import(tx_strings) + + def _finish_import(self, tx_strings): + """Build local WillItems and open the review+sign wizard.""" + items = {} + for s in tx_strings: + try: + wi = WillItem({"tx": s}, wallet=self.bal_window.wallet) + except Exception as e: + self.show_error( + _("Could not parse a transferred transaction: {}").format(e) + ) + return + items[wi._id] = wi + Will.normalize_will(items, self.bal_window.wallet) + self._local_validity_pass(items) + for wi in items.values(): + wi.set_status("IMPORTED", True) + valid = [wid for wid in items if items[wid].get_status("VALID")] + if not valid: + self.show_error( + _( + "The imported will contains no valid transaction in this " + "wallet." + ) + ) + self.close() + return + self.close() + skipped = len(items) - len(valid) + if skipped: + self.show_warning( + _( + "{} imported transaction(s) are not valid in this wallet " + "and were skipped." + ).format(skipped) + ) + wizard = WillTxReviewSignDialog( + self.bal_window, will=items, bal_plugin=self.bal_plugin + ) + if wizard.aborted: + return + show_on_top(wizard) + + def _local_validity_pass(self, items): + """Local, wallet-only validity check (no server, no expiry raise). + + Mirrors the check that :meth:`BalWindow.merge_will` runs after a merge + so the import and the file-merge paths behave identically. + """ + date_to_check = getattr(self.bal_window, "date_to_check", None) + if date_to_check is None: + date_to_check = resolve_date_to_check( + self.bal_window.bal_plugin.is_basic_mode(), + self.bal_window.will_settings, + ) + history_label = self.bal_window.bal_plugin.HISTORY_LABEL.get() + try: + Will.add_willtree(items) + all_utxos = Util.get_available_utxos( + self.bal_window.wallet, + history_label, + Will.get_min_locktime(items, default_value=date_to_check), + ) + Will.check_invalidated( + items, Will.utxos_strs(all_utxos), self.bal_window.wallet + ) + Will.search_rai( + Will.get_all_inputs(items, only_valid=True), + all_utxos, + items, + self.bal_window.wallet, + ) + Will.check_signatures(items, self.bal_window.wallet) + except Exception as e: + log_error(e, self) + + +class WillTxReviewSignDialog(BalDialog): + """Per-transaction review + sign wizard for an imported will. + + Walks the (valid) imported transactions one at a time showing outputs, + total outputs and fees, with Sign / Skip / Cancel per page. All signing + runs on the local copy of the imported will; the live will and the wallet + history are never touched. The final page offers to export the signed + transactions as a file and/or as QR codes. + """ + + def __init__(self, bal_window, will=None, bal_plugin=None): + BalDialog.__init__( + self, bal_window.window, bal_plugin, _("Review and sign imported will") + ) + self.bal_window = bal_window + self.items = will if will is not None else bal_window.willitems + self.txids = sorted(Will.only_valid(self.items)) + self.aborted = False + self.i = 0 + if not self.txids: + self.aborted = True + self.close() + return + self.password = bal_window.get_wallet_password( + message=_( + "Enter your wallet password to sign the imported transactions." + ) + ) + if self.password is False: + self.aborted = True + self.close() + return + + vbox = QVBoxLayout(self) + self.stack = QStackedWidget(self) + self.summary_page = self._build_summary_page() + # Index 0 = summary page; review pages start at index 1. + self.stack.addWidget(self.summary_page) + self.review_pages = [] + for _txid in self.txids: + page = self._build_review_page() + self.stack.addWidget(page) + self.review_pages.append(page) + vbox.addWidget(self.stack) + self.stack.setCurrentIndex(1) + self._render() + + # -- page builders --------------------------------------------------------- + + def _build_review_page(self): + page = QWidget() + vbox = QVBoxLayout(page) + header = QLabel() + vbox.addWidget(header) + txid_label = QLabel() + txid_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + vbox.addWidget(txid_label) + outputs_label = QLabel() + outputs_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + vbox.addWidget(outputs_label) + totals_label = QLabel() + vbox.addWidget(totals_label) + + row = QHBoxLayout() + sign_btn = QPushButton(_("Sign")) + sign_btn.clicked.connect(self._sign_current) + row.addWidget(sign_btn) + skip_btn = QPushButton(_("Skip")) + skip_btn.clicked.connect(self._advance) + row.addWidget(skip_btn) + cancel_btn = QPushButton(_("Cancel")) + cancel_btn.clicked.connect(self.close) + row.addWidget(cancel_btn) + row.addStretch(1) + vbox.addLayout(row) + return page + + def _build_summary_page(self): + page = QWidget() + vbox = QVBoxLayout(page) + self.summary_label = QLabel() + self.summary_label.setWordWrap(True) + vbox.addWidget(self.summary_label) + save_btn = QPushButton(_("Save signed file…")) + save_btn.clicked.connect(self._save_signed) + vbox.addWidget(save_btn) + self.qr_btn = QPushButton(_("Show signed QR…")) + self.qr_btn.clicked.connect(self._show_signed_qr) + vbox.addWidget(self.qr_btn) + close_btn = QPushButton(_("Close")) + close_btn.clicked.connect(self.close) + vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight) + return page + + # -- rendering ------------------------------------------------------------- + + def _page_index(self): + return 0 if self.i >= len(self.txids) else self.i + 1 + + def _render(self): + if self.i >= len(self.txids): + self._enter_summary() + return + txid = self.txids[self.i] + wi = self.items[txid] + tx = wi.tx + page = self.review_pages[self.i] + + headers = page.findChildren(QLabel) + headers[0].setText( + _("Transaction {} of {}").format(self.i + 1, len(self.txids)) + ) + headers[1].setText(_("TXID: {}").format(txid)) + lines = [] + for o in tx.outputs(): + value = o.value if o.value is not None else _("unknown") + if isinstance(value, int): + value_s = self.bal_window.window.format_amount_and_units(value) + else: + value_s = value + lines.append("{} {}".format(o.get_ui_address_str(), value_s)) + headers[2].setText("\n".join(lines) if lines else _("(no outputs)")) + total_out = sum( + (o.value or 0) for o in tx.outputs() if isinstance(o.value, int) + ) + fee = None + try: + iv = tx.input_value() + if isinstance(iv, int): + fee = iv - total_out + except Exception: + fee = None + if fee is not None: + fee_s = self.bal_window.window.format_amount_and_units(fee) + else: + fee_s = _("unknown (partial transaction)") + headers[3].setText( + _("Total outputs: {}\nFee: {}").format( + self.bal_window.window.format_amount_and_units(total_out), fee_s + ) + ) + self.stack.setCurrentIndex(self._page_index()) + + def _enter_summary(self): + signed = sum( + 1 for txid in self.txids if self.items[txid].get_status("COMPLETE") + ) + self.summary_label.setText( + _( + "Signed {} of {} transactions.\n\nSave a signed file to carry " + "to the broadcast device, or show the signed transactions as " + "QR codes." + ).format(signed, len(self.txids)) + ) + self.qr_btn.setEnabled(signed > 0) + self.stack.setCurrentIndex(0) + + # -- actions --------------------------------------------------------------- + + def _sign_current(self): + txid = self.txids[self.i] + try: + tx, newly = self.bal_window._prepare_and_sign_tx( + self.items, txid, self.password + ) + except Exception as e: + log_error(e, self) + self.show_error(_("Could not sign the transaction: {}").format(e)) + return + if newly and tx.is_complete(): + self.items[txid].set_status("COMPLETE", True) + self._advance() + + def _advance(self): + self.i += 1 + self._render() + + def _save_signed(self): + data = {wid: wi.to_dict() for wid, wi in self.items.items()} + + def _do_save(path): + try: + write_json_file(path, data) + except Exception as e: + self.show_error(str(e)) + return + self.show_message(_("Signed will saved.")) + + export_meta_gui(self.bal_window.window, "will_signed.json", _do_save) + + def _show_signed_qr(self): + signed = { + wid: wi + for wid, wi in self.items.items() + if wid in self.txids and wi.get_status("COMPLETE") + } + if not signed: + self.show_message(_("No signed transaction to show.")) + return + d = WillQrExportDialog(self.bal_window, will=signed, bal_plugin=self.bal_plugin) + show_on_top(d) + diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 80069f7..11939e8 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -667,7 +667,9 @@ class PreviewList(MyTreeView, MessageBoxMixin): export_menu.addAction(_("All"), self.export_will) export_menu.addAction(_("Valid"), self.export_will_valid) export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete) + export_menu.addAction(_("QR Codes"), self.export_will_via_qr) menu.addAction(_("Import"), self.import_will_into_details) + menu.addAction(_("Import via QR"), self.import_will_via_qr) menu.addAction(_("Merge"), self.merge_will) menu.addAction(_("Broadcast"), self.broadcast) menu.addAction(_("Check"), self.check) @@ -769,6 +771,12 @@ class PreviewList(MyTreeView, MessageBoxMixin): def import_will_into_details(self): self.bal_window.import_will_into_details() + def export_will_via_qr(self): + self.bal_window.export_will_via_qr() + + def import_will_via_qr(self): + self.bal_window.import_will_via_qr() + def merge_will(self): self.bal_window.merge_will_ui() diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index ac1dc92..120cb56 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -19,6 +19,8 @@ from electrum.plugin import hook from electrum.util import EventListener, event_listener from PyQt6.QtWidgets import QLayout +from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size + from .common import ( _, _logger, @@ -531,6 +533,21 @@ class Plugin(BalPlugin, EventListener): # users (BASIC and ADVANCED). heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD) + # QR Code Size selector (will transfer via QR). A 4-standard-size combo + # bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame). + # Ordered low -> high so the user picks the resolution matching their + # camera. Visible to all users (BASIC and ADVANCED). + qr_size_combo = QComboBox() + qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS]) + qr_size_combo.setCurrentIndex( + preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get())) + ) + + def on_qr_size_change(index): + self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1]) + + qr_size_combo.currentIndexChanged.connect(on_qr_size_change) + # USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo # (not a free-text field) bound to the USER_TYPE config: # index 0 -> "BASIC" -> stored value "basic" (DEFAULT) @@ -647,6 +664,10 @@ class Plugin(BalPlugin, EventListener): widget.setCurrentIndex( 1 if str(cfg.default).lower() == "advanced" else 0 ) + elif kind == "qr_size": + widget.setCurrentIndex( + preset_index_for_chunk_size(int(cfg.default)) + ) btn.clicked.connect(reset) return btn @@ -905,6 +926,25 @@ class Plugin(BalPlugin, EventListener): ) grid.addWidget(reset_btn_auto_rebuild, 15, 3) + # "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR + # size used when exporting a will via QR codes; changeable per export + # inside the export dialog itself. + lbl_qr_size = QLabel(_("QR Code Size")) + help_qr_size = HelpButton( + "Payload size of a single QR code when exporting a will via QR.\n\n" + "Larger QR codes hold more data (fewer shots) but are easier to " + "scan with a high-resolution camera; smaller QR codes scan fine " + "even with low-resolution cameras but require more shots.\n" + "The same selector is available inside the export dialog." + ) + grid.addWidget(lbl_qr_size, 16, 0) + grid.addWidget(qr_size_combo, 16, 1) + grid.addWidget(help_qr_size, 16, 2) + reset_btn_qr_size = _make_reset_btn( + self.QR_CHUNK_SIZE, qr_size_combo, "qr_size" + ) + grid.addWidget(reset_btn_qr_size, 16, 3) + # ----------------------------------------------------------------- # # Group C / C4b: "Reset" button that restores the dialog settings to # # their factory defaults. It only resets the settings exposed by THIS # @@ -938,6 +978,7 @@ class Plugin(BalPlugin, EventListener): (self.HISTORY_LABEL, edit_history_label, "line"), (self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"), (self.AUTO_REBUILD, heir_auto_rebuild, "check"), + (self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"), ] for cfg, widget, kind in resets: # Persist the default value back into the Electrum config. @@ -958,6 +999,10 @@ class Plugin(BalPlugin, EventListener): widget.setCurrentIndex( 1 if str(cfg.default).lower() == "advanced" else 0 ) + elif kind == "qr_size": + widget.setCurrentIndex( + preset_index_for_chunk_size(int(cfg.default)) + ) # Re-sync the history-label field's enabled state after a reset: the # reset restores SAVE_HISTORY to its default, so the field must # follow the (default) checkbox state again. diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 789c2b3..ab7b10f 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -88,6 +88,8 @@ from .dialogs import ( BalWizardDialog, WillDetailDialog, WillExecutorDialog, + WillQrExportDialog, + WillQrImportDialog, ) from .lists import HeirListWidget, PreviewList from .widgets import LockTimeWidget, PercAmountEdit @@ -980,6 +982,13 @@ class BalWindow: return self.show_transaction_real(tx, parent=parent) def invalidate_will(self, will=None): + # The reference timestamp is normally set by init_class_variables(); + # fall back to "now" so a first-action invalidation always has it. + if not hasattr(self, "date_to_check") or self.date_to_check is None: + self.date_to_check = resolve_date_to_check( + self.bal_plugin.is_basic_mode(), self.will_settings + ) + def on_success(result): if result: self.show_message( @@ -1015,75 +1024,93 @@ class BalWindow: self.waiting_dialog.exe() def sign_transactions(self, password, will=None, txids=None): - try: - willitems = will if will is not None else self.willitems - txs = {} - signed = None - tosign = None + try: + willitems = will if will is not None else self.willitems + txs = {} + signed = None + tosign = None - def get_message(): - msg = "" - if signed: - msg = _(f"signed: {signed}\n") - return msg + _(f"signing: {tosign}") + def get_message(): + msg = "" + if signed: + msg = _(f"signed: {signed}\n") + return msg + _(f"signing: {tosign}") - if txids is not None: - targets = [ - t for t in txids - if t in willitems and willitems[t].get_status("VALID") - ] - else: - targets = Will.only_valid(willitems) - for txid in targets: - wi = willitems[txid] - # Do NOT deepcopy: the stored tx carries wallet-derived objects - # (utxo / script_descriptor) that hold a threading.RLock, and - # copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse - # from the serialized form instead, which is exactly how the will - # is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any). - tx = Will.get_tx_from_any(str(wi.tx)) - if wi.get_status("COMPLETE"): + if txids is not None: + targets = [ + t for t in txids + if t in willitems and willitems[t].get_status("VALID") + ] + else: + targets = Will.only_valid(willitems) + for txid in targets: + wi = willitems[txid] + if wi.get_status("COMPLETE"): + # Already signed and complete: keep as-is (the single-tx + # helper short-circuits without touching the wallet). + tx, _ = self._prepare_and_sign_tx(willitems, txid, password) + txs[txid] = tx + continue + tosign = txid + try: + self.waiting_dialog.update(get_message()) + except Exception: + pass + tx, _signed = self._prepare_and_sign_tx(willitems, txid, password) + signed = tosign txs[txid] = tx - continue - tosign = txid + except Exception: + return None + return txs + + def _prepare_and_sign_tx(self, willitems, txid, password): + """Prepare one will transaction and sign it. + + Shared by the batch signer (:meth:`sign_transactions`) and the + per-transaction review wizard of the QR import flow + (:class:`WillTxReviewSignDialog`). + + Returns ``(tx, newly_signed)``: ``newly_signed`` is False when the + transaction was already COMPLETE (nothing was signed). + """ + wi = willitems[txid] + # Do NOT deepcopy: the stored tx carries wallet-derived objects + # (utxo / script_descriptor) that hold a threading.RLock, and + # copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse + # from the serialized form instead, which is exactly how the will + # is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any). + tx = Will.get_tx_from_any(str(wi.tx)) + if wi.get_status("COMPLETE"): + return tx, False + for txin in tx.inputs(): + prevout = txin.prevout.to_json() + if prevout[0] in willitems: + change = willitems[prevout[0]].tx.outputs()[prevout[1]] + txin._trusted_value_sats = change.value try: - self.waiting_dialog.update(get_message()) + txin.script_descriptor = change.script_descriptor except Exception: pass - for txin in tx.inputs(): - prevout = txin.prevout.to_json() - if prevout[0] in willitems: - change = willitems[prevout[0]].tx.outputs()[prevout[1]] - txin._trusted_value_sats = change.value - try: - txin.script_descriptor = change.script_descriptor - except Exception: - pass - txin.is_mine = True - txin._TxInput__address = change.address - txin._TxInput__scriptpubkey = change.scriptpubkey - txin._TxInput__value_sats = change.value + txin.is_mine = True + txin._TxInput__address = change.address + txin._TxInput__scriptpubkey = change.scriptpubkey + txin._TxInput__value_sats = change.value + txin._trusted_value_sats = change.value - self.wallet.sign_transaction(tx, password, ignore_warnings=True) - signed = tosign - # is_complete = False - if tx.is_complete(): - # is_complete = True - wi.set_status("COMPLETE", True) - # Refresh the per-item signature counts from the freshly signed - # partial tx: at this point the signatures are still present - # (before any finalization), so the will list can show the real - # "added/required" count (e.g. "1/2" for a multisig). - try: - have, required = tx.signature_count() - wi.sigs_have = int(have) - wi.sigs_required = int(required) - except Exception as e: - _logger.debug(f"signature_count after signing failed: {e}") - txs[txid] = tx - except Exception: - return None - return txs + self.wallet.sign_transaction(tx, password, ignore_warnings=True) + if tx.is_complete(): + wi.set_status("COMPLETE", True) + # Refresh the per-item signature counts from the freshly signed + # partial tx: at this point the signatures are still present + # (before any finalization), so the will list can show the real + # "added/required" count (e.g. "1/2" for a multisig). + try: + have, required = tx.signature_count() + wi.sigs_have = int(have) + wi.sigs_required = int(required) + except Exception as e: + _logger.debug(f"signature_count after signing failed: {e}") + return tx, True def get_wallet_password(self, message=None, parent=None): parent = self.window if not parent else parent @@ -1620,6 +1647,52 @@ class BalWindow: self.show_error(str(e)) raise e + def export_will_via_qr(self, will=None): + """Export the will (default: the live one) as QR codes on screen. + + The selected transactions are serialized with the wire format of + :mod:`bal.core.qrtransfer` and shown, one frame at a time, in a + :class:`WillQrExportDialog`. When Electrum's ``audio_modem`` plugin + is available (:meth:`get_audio_modem_plugin`) the dialog also offers + an "Audio" send button. + """ + try: + willitems = will if will is not None else self.willitems + d = WillQrExportDialog(self, will=willitems, bal_plugin=self.bal_plugin) + show_on_top(d) + except Exception as e: + self.show_error(str(e)) + raise e + + def get_audio_modem_plugin(self): + """Return Electrum's ``audio_modem`` plugin instance, or None. + + The plugin is only usable when Electrum exposes it (the ``Plugins`` + manager knows the name) and its optional runtime dependency + ``amodem`` is installed (:meth:`is_available`). Every other case + returns None so callers can simply hide the audio buttons. + """ + try: + p = self.window.gui_object.plugins.get("audio_modem") + except Exception: + return None + if not p or not getattr(p, "is_available", lambda: False)(): + return None + return p + + def _audio_send_payload(self, payload): + """Send a transfer payload through the audio_modem plugin. + + Wraps the plugin's own ``_send`` with a proper parent widget. The + audio channel zlib-compresses internally, so the payload is passed + uncompressed (no BAL ``Z`` flag needed on that transport). + """ + plugin = self.get_audio_modem_plugin() + if plugin is None: + self.show_error(_("Audio MODEM plugin is not available.")) + return + plugin._send(parent=self.window, blob=payload) + def merge_will(self, imported): """Merge imported will items into the live will. @@ -1762,6 +1835,18 @@ class BalWindow: import_meta_gui(self.window, _("will"), on_file, on_success) + def import_will_via_qr(self): + """Import a will through QR codes (or audio) and review/sign it. + + Opens a :class:`WillQrImportDialog`. The captured transactions are + parsed into fresh :class:`WillItem` objects (never touching the + live will), run through the same local validity pass the merge flow + uses, and are then presented in the per-transaction review wizard + (:class:`WillTxReviewSignDialog`). + """ + d = WillQrImportDialog(self, bal_plugin=self.bal_plugin) + show_on_top(d) + def _load_will_file(self, path): data = read_json_file(path) willitems = {} diff --git a/tests/test_core_qr_transfer.py b/tests/test_core_qr_transfer.py new file mode 100644 index 0000000..51d924b --- /dev/null +++ b/tests/test_core_qr_transfer.py @@ -0,0 +1,327 @@ +""" +Tests for ``bal.core.qrtransfer``. + +Covers the BALQR frame encoding used for will transfer via QR codes / +audio modem: encoding, framing, reassembly, malformed input and the preset +list (optionally cross-checked against the ``qrcode`` library's EC-M +capacity when it is installed). + +Run: + source electrum/env/bin/activate + python3 tests/test_core_qr_transfer.py +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) + +from bal.core.qrtransfer import ( + CHUNK_PRESETS, + MIN_CHUNK_SIZE, + InconsistentTotalError, + MissingFramesError, + QrTransferError, + assemble, + decode_transfer, + encode_transfer, + parse_frame, + preset_index_for_chunk_size, + split_frames, +) + + +def _frames(tx_strings, chunk_size, compress=False): + """Split a payload and return (payload, total, {index: payload}).""" + payload = encode_transfer(tx_strings, compress=compress) + parsed = {} + total = None + for frame in split_frames(payload, chunk_size, compressed=compress): + t, index, _compressed, p = parse_frame(frame) + if total is not None: + assert total == t + total = t + parsed[index] = p + assert total is not None + return payload, total, parsed + + +# --------------------------------------------------------------------------- # +# Round trips +# --------------------------------------------------------------------------- # + + +def test_encode_decode_plain(): + tx_strings = ["00" * 32, "aa" * 40, "ff" * 50] + payload = encode_transfer(tx_strings, compress=False) + assert decode_transfer(payload, compressed=False) == tx_strings + + +def test_encode_decode_compressed(): + tx_strings = ["00" * 32, "aa" * 40, "ff" * 50] + payload = encode_transfer(tx_strings, compress=True) + assert decode_transfer(payload, compressed=True) == tx_strings + + +def test_empty_list_roundtrip(): + assert decode_transfer(encode_transfer([]), compressed=False) == [] + + +# --------------------------------------------------------------------------- # +# Framing +# --------------------------------------------------------------------------- # + + +def test_single_frame(): + tx_strings = ["11" * 10] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + assert len(frames) == 1 + total, index, compressed, p = parse_frame(frames[0]) + assert (total, index, compressed) == (1, 1, False) + assert p == payload + + +def test_multiple_frames_reassemble(): + tx_strings = ["ab" * 100, "cd" * 100] # 600 chars -> multiple frames + _payload, total, parsed = _frames(tx_strings, CHUNK_PRESETS[0][1]) + assert total > 1 + decoded = decode_transfer(assemble(parsed, total), compressed=False) + assert decoded == tx_strings + + +def test_size_greater_than_payload(): + tx_strings = ["12" * 5] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 1800) + assert len(frames) == 1 + _t, _i, _c, p = parse_frame(frames[0]) + assert p == payload + + +def test_exact_single_frame_boundary(): + # A 138-byte payload exactly fills the 150-byte preset budget (the 12-char + # empty-flags header plus payload), so the encoded frame is exactly 150. + tx_strings = ["a" * 138] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + assert len(frames) == 1 + assert len(frames[0]) == 150 + _t, _i, _c, p = parse_frame(frames[0]) + assert p == payload + + +def test_frames_fit_chunk_size(): + tx_strings = ["".join("{:02x}".format(i) * 2) for i in range(300)] + payload = encode_transfer(tx_strings) + for _label, size in CHUNK_PRESETS: + for frame in split_frames(payload, size): + assert len(frame) <= size, (size, len(frame)) + + +def test_single_tx_larger_than_chunk(): + # A huge serialized tx must be split over several frames and reassemble + # exactly (positional slicing is safe for hex/base64 text). + tx_strings = ["7b" * 1000] # 2000 chars + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + assert len(frames) > 1 + parsed = {parse_frame(f)[1]: parse_frame(f)[3] for f in frames} + total = parse_frame(frames[0])[0] + assert assemble(parsed, total) == payload + + +def test_compressed_frames_carry_flag(): + tx_strings = ["ab" * 40] + frames = split_frames(encode_transfer(tx_strings, compress=True), 150, compressed=True) + for frame in frames: + _t, _i, compressed, _p = parse_frame(frame) + assert compressed is True + # Plain frames do not. + frames_plain = split_frames(encode_transfer(tx_strings), 150) + _t, _i, compressed, _p = parse_frame(frames_plain[0]) + assert compressed is False + + +def test_compressed_roundtrip_through_frames(): + tx_strings = ["ab" * 50, "cd" * 50, "12" * 60] + payload = encode_transfer(tx_strings, compress=True) + parsed = {} + total = None + for frame in split_frames(payload, 400, compressed=True): + t, index, _c, p = parse_frame(frame) + total = t + parsed[index] = p + assert total is not None + decoded = decode_transfer(assemble(parsed, total), compressed=True) + assert decoded == tx_strings + + +# --------------------------------------------------------------------------- # +# Malformed input +# --------------------------------------------------------------------------- # + + +def test_parse_bad_magic_and_version(): + for frame in ( + "BALQR|1|1||a", # missing version + "BALQR2|1|1||a", # unknown version + "XXXXX1|1|1||a", # unknown magic + ): + try: + parse_frame(frame) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for: {}".format(frame)) + + +def test_parse_bad_arity(): + for frame in ("BALQR1", "BALQR1|1|1|"): + try: + parse_frame(frame) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for: {}".format(frame)) + + +def test_parse_pipe_in_payload_is_folded(): + # maxsplit keeps the tail (including any inner '|') in the payload part. + frame = "BALQR1|1|1||a|b|c" + total, index, compressed, payload = parse_frame(frame) + assert (total, index, compressed) == (1, 1, False) + assert payload == "a|b|c" + + +def test_parse_bad_numbers(): + for frame in ( + "BALQR1|x|1||a", + "BALQR1|1|y||a", + "BALQR1|0|1||a", + "BALQR1|1|0||a", + "BALQR1|1|2||a", # index beyond total + "BALQR1|-1|1||a", + ): + try: + parse_frame(frame) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for: {}".format(frame)) + + +def test_parse_bad_flags(): + try: + parse_frame("BALQR1|1|1|Q|payload") + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for unknown flags") + + +def test_assemble_missing_frames(): + try: + assemble({1: "a", 3: "c"}, total=3) + except MissingFramesError as e: + assert e.missing == [2] + else: + raise AssertionError("expected MissingFramesError") + + +def test_assemble_index_beyond_total(): + try: + assemble({1: "a", 2: "b"}, total=1) + except InconsistentTotalError: + pass + else: + raise AssertionError("expected InconsistentTotalError") + + +def test_assemble_order_and_total_validation(): + assert assemble({1: "a", 2: "b"}, total=2) == "ab" + try: + assemble({}, total=0) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError") + + +# --------------------------------------------------------------------------- # +# Constants / presets +# --------------------------------------------------------------------------- # + + +def test_preset_count_and_order(): + assert len(CHUNK_PRESETS) == 4 + budgets = [budget for _label, budget in CHUNK_PRESETS] + assert budgets == sorted(budgets) + + +def test_preset_index_for_chunk_size(): + for index, (_label, budget) in enumerate(CHUNK_PRESETS): + assert preset_index_for_chunk_size(budget) == index + assert preset_index_for_chunk_size(150) == 0 + assert preset_index_for_chunk_size(1800) == 3 + + +def test_min_chunk_size_guard(): + try: + split_frames("x" * 10, MIN_CHUNK_SIZE - 1) + except QrTransferError: + pass + else: + raise AssertionError("expected QrTransferError for tiny chunk size") + + +def test_split_frame_headers_consistent(): + tx_strings = ["ab" * 80] + payload = encode_transfer(tx_strings) + frames = split_frames(payload, 150) + totals = {parse_frame(frame)[0] for frame in frames} + assert len(totals) == 1 + assert totals.pop() == len(frames) + + +# --------------------------------------------------------------------------- # +# Optional: cross-check presets against the qrcode library (EC level M) +# --------------------------------------------------------------------------- # + + +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") + return + for _label, size in CHUNK_PRESETS: + # Worst-case frame: header with the largest plausible total/index plus + # a full payload of the preset budget. + frame = "BALQR1|9999|9999|Z|" + "a" * (size - 14) + qr = qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2) + qr.add_data(frame) + qr.get_matrix() # raises DataOverflowError if it does not fit + + +# --------------------------------------------------------------------------- # +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") \ No newline at end of file diff --git a/tests/test_gui_qr_transfer.py b/tests/test_gui_qr_transfer.py new file mode 100644 index 0000000..d73b762 --- /dev/null +++ b/tests/test_gui_qr_transfer.py @@ -0,0 +1,343 @@ +""" +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. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py +""" + +import base64 +import sys +from unittest.mock import patch + +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +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.will import WillItem + +_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), +# reused for the WillItem status regression test. +_VALID_TX_HEX = ( + "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" + "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" + "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" + "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" + "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" + "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" + "42146f11ef8414ae929feaafc388ac00000000" +) + + +class FakePlugin: + 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 FakeBalWindow: + """Duck-typed stand-in for BalWindow (dialog layer only).""" + + def __init__(self): + self.window = FakeWindow() + self.bal_plugin = FakePlugin() + self.willitems = {} + + def get_audio_modem_plugin(self): + return None + + +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 _make_willitems(n=3, payload_len=120): + return { + "item{}".format(i): StubWillItem("T{}".format(i) * payload_len) + for i in range(n) + } + + +# ------------------------------------------------------------------ # +# WillQrExportDialog +# ------------------------------------------------------------------ # + +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 + # Frame 1 is shown. + assert d.qr_view.text == d.frames[0] + assert "1" in d.progress_label.text() + d.close() + + +def test_export_dialog_empty_close(): + # An empty will shows a modal message; stub it 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) + assert not d.isVisible() + d.close() + finally: + dialogs.MessageBoxMixin.show_message = orig + + +def test_imported_item_status_not_none(): + # Regression: a WillItem built from a bare {"tx": hex} had a None status, + # so set_status (e.g. IMPORTED / INVALIDATED from the import validity + # pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and + # 'str'". + with patch.object(Transaction, "add_info_from_wallet"): + wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None) + assert wi.status == "" + assert wi.set_status("IMPORTED", True) is True + assert wi.set_status("INVALIDATED", True) is True + assert "Imported" in wi.status and "Invalidated" in wi.status + + +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.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") + + # 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() + 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 + + # 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() + + # 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() + d.close() + + +def test_export_filter_valid_and_valid_nc(): + bw = FakeBalWindow() + a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) + 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 + assert d.filter_combo.count() == 3 + + # "Valid" filter -> only the valid items (a, b). + d._on_filter_change(1) + assert sorted(d.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] + d.close() + + +def test_export_filter_empty_reverts(): + bw = FakeBalWindow() + # Only a COMPLETE valid item: "Valid NC" selects nothing -> revert. + bw.willitems = { + "a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) + } + d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) + messages = [] + d.show_message = lambda msg: messages.append(msg) + 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 + 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) + 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] + + # 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 + d.close() + + +# ------------------------------------------------------------------ # +# WillQrImportDialog +# ------------------------------------------------------------------ # + +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() + + 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() + # 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() + # Duplicate capture is harmless. + d._add_frame(frames[0]) + assert len(d.frames) == d.total + d.close() + + +def test_import_assembles_and_decodes(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + captured = {} + + def fake_finish(tx_strings): + captured["tx_strings"] = tx_strings + + d._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] + d.close() + + +def test_import_total_mismatch_resets(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + warnings = [] + d.show_warning = lambda msg: warnings.append(msg) + + 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) + + # 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]) + assert warnings + assert d.total == 0 + assert not d.frames + assert not d.review_btn.isEnabled() + d.close() + + +def test_import_manual_entry(): + bw = FakeBalWindow() + d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) + 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() + d.close() + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All QR transfer GUI tests passed") From ce3e36db701b70a0172f939199d14610a4a85e83 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Fri, 28 Aug 2026 16:28:59 -0400 Subject: [PATCH 2/5] chore: stop tracking tests/karen7 (generated wallet fixture); add to .gitignore --- .gitignore | 1 + tests/karen7 | 2700 -------------------------------------------------- 2 files changed, 1 insertion(+), 2700 deletions(-) delete mode 100644 tests/karen7 diff --git a/.gitignore b/.gitignore index 472a39c..e3b7884 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ tmp* # Release artifacts bal_v*.zip.* +tests/karen7 diff --git a/tests/karen7 b/tests/karen7 deleted file mode 100644 index e39a163..0000000 --- a/tests/karen7 +++ /dev/null @@ -1,2700 +0,0 @@ -{ - "active_forwardings": {}, - "addr_history": { - "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk": [], - "bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp": [], - "bcrt1q08atkh7p3xu5cn4azclc7tcuuv7332kmjfjjlr": [], - "bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy": [], - "bcrt1q0k6rq9xny2jnk9z95w9x5zv8vkkc25d8dq2pmz": [], - "bcrt1q0ma5z02ma7q0cq7j49wz66clf69xw3fq2zl05n": [], - "bcrt1q0tf3hnml0s03rsqfjzmm4tlnxu9k0969yqvhn9": [], - "bcrt1q0tq2hlcdtla0evqlnheulmgth36ps49cjchpy7": [], - "bcrt1q0vsaz2czkjppru02e3mgqc696g925mtmkmqz0t": [], - "bcrt1q0wjhd2h4t6ze0jpnzh0cxkg4sst5djz8tjv5e6": [], - "bcrt1q22ujjfckcg8yenr7j99gtam99tp94a8ayhul5l": [], - "bcrt1q25eueqeuarx3gz8l8jdascp4r456h9gydxmezx": [], - "bcrt1q28qtj5ugfcm4psqenq54sh55uryealeeprk6ju": [], - "bcrt1q2c23j6juuw5r6ryr59ew5j5xnfv5e57qwlprz7": [], - "bcrt1q2ds58hh500vl9f2sepudvw5d7qvj30aqly6vtp": [], - "bcrt1q2ephrnnnl80je2jshfqwxszynrv8lkvlx6nntg": [], - "bcrt1q39nz55p7yf7zmkyemft76g6nwu9qg3gskjeva3": [], - "bcrt1q3psyk95gkm3qeeuppa35x80ukm8gguumymky3h": [], - "bcrt1q3wapj9c8x8lsyszsj4vn62uh3q3930a88eyx0h": [], - "bcrt1q3yhmfm0sx8r3t8g4tmu78ye56zl8kpqplx0nmv": [], - "bcrt1q3zc3uh3rjj88zn402u45uq7vqqcesf02esnw3t": [], - "bcrt1q4623v9ch4qq4f6y3fqgvd255gseens6n8693t4": [], - "bcrt1q46hqphdj9u3xtwlazenlh2jja5u97er3pl64hm": [], - "bcrt1q4gcw8qp4tv9a8vmx5day4zjqgdmzf5nafvwdvy": [], - "bcrt1q4j7meqhavz4vz45wq2yh0sm3czl5t2n09q0s5j": [], - "bcrt1q4kzsek2w6kg88ptrd5g2r9202fhvxuchjgwvyc": [], - "bcrt1q4pwagwjh9stnxrvf03wnd0lx3se9zrjdz2akhd": [], - "bcrt1q4tgtdu4x3y574yx0v3cc4n66agdvezgccnyyay": [], - "bcrt1q4wle4rjunlheyrpx3cgewjry84lt9vcfn6dzm7": [], - "bcrt1q53q6gv5upwc9lp6ny4yexrqnesvmpjzm8vu62j": [], - "bcrt1q53ujhg0nuk30afuqdvr02lcy73g6397pffpf7u": [], - "bcrt1q55mx93y6xpwuz8kr2aqvaydmyxqh2r8fmrdmgt": [], - "bcrt1q598a6uzeawe5anarp8lzdc3wkhnrewgr487lh0": [], - "bcrt1q5a2zqf9tdhcv5x80fp0re9fne8uv2palye4adw": [], - "bcrt1q5czlklraxkevcc3j6rhz7gg0uw8t0afefxrd5h": [], - "bcrt1q5e5sd3szwguue835pqx3zph92zym3zjs9qargr": [], - "bcrt1q5jtmty5pyhgq6cer4adqgjfukvzrup35k6eavu": [], - "bcrt1q5kcw579e5cy2xkvkv3725yjqjtz65t8upmka46": [], - "bcrt1q5y9w84tf3xfxss6u6eueejmk7pmcssnz0g5hzp": [], - "bcrt1q5zmlx78xftvmz5dmwj0hpaa8v7myeu7a6pctk0": [], - "bcrt1q62u2fdqsdzf3t6qmx4ykncm9s3z3lp2nmtkudl": [], - "bcrt1q675dp6jvwpluld0lde7f0qs4gxuzv5ywe3z24l": [], - "bcrt1q68gsfew2uccu743dtgwg9gf74k3zs0va9nyfg3": [], - "bcrt1q6f9dr757kgrknaaygmqkg82h0apvnz303p8x5e": [], - "bcrt1q6h648hj3ewwyset0qh6u40r2hexdezu2nvfhuf": [], - "bcrt1q6jjejjzq3yks4kxzy5yxpkagyehlyekyvvsdkk": [], - "bcrt1q6k48erknye22qdnae8ajg7rjl5mck986557zcq": [], - "bcrt1q6sny3g2erarczmcpnd7clc8njdzqqzhc5y68wg": [], - "bcrt1q6uhp3ygh6xz9jd9rspsnl2kumec6a546d63s2v": [], - "bcrt1q6vxuvwrt8x5c9u9u29y5uq7frscr0vgc2dy60j": [], - "bcrt1q79vkkrqqcfalzzvdmjfpd6nc69kzl92sncj4sc": [], - "bcrt1q7d5fzxjw3j23vpkfcjy8xjhkvww6lytmh46tar": [], - "bcrt1q7hlkjye65lawffksuc59lwp8246g2lfeq6xeqh": [], - "bcrt1q7lu93azk4a39zvgcz3ps5vyxdgdmrplr5dtdlr": [], - "bcrt1q7mkzsmw3ydmxp0env4d42s49a5fjghq5qghkg4": [], - "bcrt1q895s7qgvkmhq4vqcpcqar20kkdqh4urtmx9e0h": [], - "bcrt1q8n43787xqpuuzepj5u7as307lvwp6vnn9rw76y": [], - "bcrt1q8vx3c8mqnlhymgfd27fjw3sm64j96j0zg062qf": [], - "bcrt1q953hr33ww3d5qspq6haxe6gj4djm3atuns9kas": [], - "bcrt1q96tpq4e8al6t6fs4u2zmvkdwtck9688x4c8qa0": [], - "bcrt1q97dhvm6mpxe9jehele2d7gehrgq4ruxjevaecf": [], - "bcrt1q9eh96kvwt6x4wrk7hwl6ulmquey2euk32dyefc": [], - "bcrt1q9hjzp5hkngmnn3e58xxrvqs9npjuxtfwfqrerf": [], - "bcrt1q9pecdfdkwqjcn7wt8ggxtx5nq2e0hu0zwqr5p3": [], - "bcrt1q9s5wudq6tqapdhezejm2uxek6f84jwx082l0kq": [], - "bcrt1q9ypfeksnupu5pyy05zv6uaflaqza3vvfdk9wwm": [], - "bcrt1q9z0w3qn7j7hq2da6fuyjrt5tjn5edyytnz45p7": [], - "bcrt1qa9ezp0pcdlryz6c5weuhd4l0ty2w3wnmsppfvq": [], - "bcrt1qa9ls0ftcln068j72a070fdg5ffauqk3vvxvqzm": [], - "bcrt1qad444emwaynna3hxraedkfucsczc88fgcq935s": [], - "bcrt1qad5z7m5w6fqn43etjr4c8fkcd2s87dsmkkkk5v": [], - "bcrt1qafme7ruwyqcrft39e4c72tq6sxky3r3tl7s69y": [], - "bcrt1qagkfjmgxal4ewa0n0uxcgmsg4vvlag7vrymcs9": [], - "bcrt1qakp5mpe00cl4a2k2kn79yjwlv58xymaxwft77d": [], - "bcrt1qal7npkkvathwgyjrx59ken3e0azh3ze8suwsn5": [], - "bcrt1qaqxp2n8amq23wy2qs7nqjsha8c9drvh0xja65q": [], - "bcrt1qattknz0hrh4ylxmle7ls9nnnluveltu6n7jdxd": [], - "bcrt1qaxj23v6mp5tj2wyvzh0p0xuu0jhh74vjkk3dnu": [], - "bcrt1qazle627r46apscly8lj4q5cxfxgrtuew879jde": [], - "bcrt1qcmlhh9n0q6ksl0yqyt0ujyg8dajvpj08mpgfzx": [], - "bcrt1qcv3kwkf2p6at0x9gtm7fumdyv33wlfys727z6d": [], - "bcrt1qd20cgmpdu9ada0g0ygthl78kvmqag22kt78cqc": [], - "bcrt1qd3hm8xsyqc7uke6fpn0ex90phqk3zf5t7e4g7f": [], - "bcrt1qd8mjyvldlnu8vutdcd5edcfdfdkme8gw2q29mk": [], - "bcrt1qda4kjau4uaut6cy4y9pkcwer99ndmy36wx73rc": [], - "bcrt1qddhje929uxy03eumyhgfrw8q5g90sdjtnj83zk": [], - "bcrt1qdfwuj5hh8pqxqwnw8kj4lgu2emct8hutfvuupu": [], - "bcrt1qdh5srlj74l86tq0ltg4gs3ujtdt0jy4hmt8ayg": [], - "bcrt1qdhd6dytgkvcf0gjqkuccdsukn5hqrtwgasvuep": [], - "bcrt1qdm3u5u35ygu9p8yajl2xz25vp80utkryqeynyx": [], - "bcrt1qdm5gmeakf72vlxqkuh20td3xamn57fn9rv9uvq": [], - "bcrt1qdnjd24626q4av9kcl373gjml2wlx7qrwhtndpp": [], - "bcrt1qdr5n0jk08cfdp67scg2v2v2ljnfxtgpxlvccq5": [], - "bcrt1qdtf5wkftsta3g5l93fv2rmdvcnlnhzuwjlpse2": [], - "bcrt1qduflrcwtynwercqatssgzjyvvslg5we5e58lma": [], - "bcrt1qdwp5jq4avgnm7ud38zz9hla8wgevc6q6r5krdj": [], - "bcrt1qe03yytnr8c8zqdrje9hza0w60llvveam52tes2": [], - "bcrt1qecqclzx9tsd9q2lvfy9vumf2seht5tsxc335tm": [], - "bcrt1qemzatwg2gwvznm8u5jg4pau79x2htt8wus8usd": [], - "bcrt1qf37fqcgrv5hhs7cw6qsge62unnzhptgjef9yuc": [], - "bcrt1qf42akvcychqwv7gax2lvtt8d6snk6yk6j9dfpt": [], - "bcrt1qf8cn6ymvu4k235fljv5cp9tjav7nt6cc3jl7rf": [], - "bcrt1qfcpncqwn9y7082hl343qtlrp0sv5kavq677f57": [], - "bcrt1qfjcf0sczuqey9sxnxs2wrpqm2h27lmmwuxyqrc": [], - "bcrt1qfjed6f75aqplfejyrly4jp6knj28ezqt5m99s6": [], - "bcrt1qflvrlzvxe583wp6ycv49v6zcs9a6e4cd0ctp3q": [], - "bcrt1qfzjp9nzu5pvjz25lz0r6cj2upwq0slpah8e76z": [], - "bcrt1qg2dhn0uhulagdu8gqlv0mgdwthqumjnaw45est": [], - "bcrt1qggc9a8nf9vjnjrru8v869zswhycajrsudfz4g4": [], - "bcrt1qghmsds7h0r00nqx4m38jjn43fl6h93pkxurfz5": [], - "bcrt1qglajmp82d6zu6fjh5aw92fe5qny9yh337awktw": [], - "bcrt1qgpew0excsv7wwfqfqvysdu3rc4x5jzrmwz06cq": [], - "bcrt1qgv0wu4v6kjzef5mnxfh2m9z6y7mez0ja0tt8mu": [], - "bcrt1qgx5kpesp2c6rlckln2vrmyt3xgvkj8pafx42tw": [], - "bcrt1qgzae4gqwpmxd92z40aasr78tdere40k784j55y": [], - "bcrt1qh2c83yulvs7kgw0g6q3lkxqws4cnf0uxpcgcpt": [], - "bcrt1qh7kz39tg9csrlq6w8lfyzkgr7zw73ake5g36zk": [], - "bcrt1qhdzn35wanehka6jdqftfhl52fflhr4rzeph4az": [], - "bcrt1qheft2thc3ee350rx2epjxk25wlsc86f9970mvg": [], - "bcrt1qhgvdtm4f59q8wd4tp4xsd9y4sa6p4vlwyqeq33": [], - "bcrt1qhk0l9kfwssrqnq956kcnhym46vr54hrcdua4z4": [], - "bcrt1qhk923893hrl3f6a7dcxsmnuutlf84x87djqy3u": [], - "bcrt1qhtd4lnehrw4qstwu4dr27pa5jv6wghygwrmmpl": [], - "bcrt1qhur55ueke9u6fd5vc55r5yv7nkkewgq5lcqgeg": [], - "bcrt1qj2kvwhrx3g768enlv4jukstffqkd54q45y6a7t": [], - "bcrt1qj3kdllghxnks02mjksk33q44ekq4xax9r2a2mq": [], - "bcrt1qj7ytvlc2fqkzfesnysa6zgmpqs7dtytu5lp7xa": [], - "bcrt1qjazy6ds2z9f5hdmz7p5xt334k9jr2n0mvq2zah": [], - "bcrt1qjju6j0yjupsvg7auel7d98yuc44emgzuxvudpx": [], - "bcrt1qjugc5864qys7hl4hsall78yu62js3epf9eqx3p": [], - "bcrt1qk5w99xz36c2q7x3h724ydwh7zud9f8epsys0yx": [], - "bcrt1qktdpn2ekdd9z7zwkm53nrykexw2yfj5cqpkazt": [], - "bcrt1qkwpwv2gkaapmdctuscj8dnjrw737ysf7efc7jz": [], - "bcrt1ql07hrgtawfttwaxzw2dqnvnfj7y3qhucprr7vl": [], - "bcrt1ql8z3qj3ckpn4wkluqga97keg3e6cw68h384k8y": [], - "bcrt1qldqtwzsy0wavrh8vddlah2jxm38hx8xpmn692s": [], - "bcrt1qlru6t7c4gr6htfps9lrray6lzu3u38w0fmt74n": [], - "bcrt1qm0q9dq7km245yrhmmx56lv8y8duzn560nq8j4p": [], - "bcrt1qm4932l4l2spedr6fgf7kh46975t3xgj87trrwj": [], - "bcrt1qm9gm7h6mmz0ygwtptcmq2c7njqghfs0mdle7uw": [], - "bcrt1qmhgnycwt2zw3kna6qq9dw4f88rn3qv9f2zks2f": [], - "bcrt1qmhqd4zyqmvurgjx43zrge562k7thnhtufk26w0": [], - "bcrt1qmmtewyxx5rrqaayr5htmvft7a5w5kcaeswyku3": [], - "bcrt1qmtpg4kh65cye2r4mwffqf2eq37h4wpnze6h6wc": [], - "bcrt1qmxcczs70me327dm7a3uzcj3ccp7tpsv3s025xv": [], - "bcrt1qmzx9eh5lljf4edqz48fe7gktndewvpmc6h9q0y": [], - "bcrt1qn69fe8fjlwyx0eqhzxl4wmypj8re3j6sq5m9zk": [], - "bcrt1qn6erz7549527kdfakz7fl460rx4yqfy8dt885j": [], - "bcrt1qn8pnsc9mhl8xhjmqlt74dy5nk4n5v9exg63e24": [], - "bcrt1qnan56c8ndmdu6afzqrlmkjscklh4dceyrc6czl": [], - "bcrt1qndy4dnx4888zr294q205fxyqqwvsa9ndd9gtn7": [], - "bcrt1qnf62mcnu7v8vevdukxyqhnk5jg285c7fpkfjgs": [], - "bcrt1qns77fmn370m7e47kxkfxelt4av5jq5qvmawvu9": [], - "bcrt1qnupz7geuaqcv85fcv03aecsxk5srxyru672r76": [], - "bcrt1qnv945mwpkelfcff8eternqmky20c77dcpl700q": [], - "bcrt1qnzc684efa9sg3nrvsdqgkdh6zwpvfa8ndm794t": [], - "bcrt1qp3txzmnma0tguhfaqm503mrusg6hx2j9ltvhxw": [], - "bcrt1qp953wxttq5h0248sf667ef5rfqqdm8v9u5kxmx": [], - "bcrt1qpcjaq6ajxm89d8ff49pvvf3uus2ulkzmzdqhr0": [], - "bcrt1qpdrremq4qh2lsgm83cymqp7m740zawuumc73x5": [], - "bcrt1qpfkl4cd2txk2n94h9cqvamp6yhzd2c496tnp65": [], - "bcrt1qpm5utekdtmzwnlkh7jq5497vwwf6sm38tljan5": [ - [ - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525", - 304 - ], - [ - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1", - 309 - ] - ], - "bcrt1qpqkpas6rxa9w9gc36d0h0h69mq24ndjzpadq60": [], - "bcrt1qpxrshlss34900q50yadm7he9lulhg3quu39jja": [], - "bcrt1qqa6f5ueq0ke6pk2dc7rfqxmwnl0pctjk3qtafy": [], - "bcrt1qqeauxxl0gu7atj98se3wrtuvltdu8wchhjvp2h": [], - "bcrt1qqemhw0pts3j5yxktm66qkkugmvhc97nwh7ys7h": [], - "bcrt1qqjxqw8hgznx5yvwca7qh6gaemxfateu96dtza6": [], - "bcrt1qqjyhj6qlcm0u9hmj9wvdhr3kmafx9wkdlrehz6": [], - "bcrt1qqkhl49s7zws7rrs0drahfjg08rntvm00d58snp": [], - "bcrt1qqng6p60x7zuk3yezjgh885k23fw9fltazmn7jx": [], - "bcrt1qqzs4as99phwumjj29gmj8w0g4x2retcwj0racy": [], - "bcrt1qr2s35k00k875c7jhkw230rlzdvg2epvy3kymml": [], - "bcrt1qr6wwuv79thuepe2cygwn55swx943q2td9ce8ja": [], - "bcrt1qrmjaxllejgqu6azftsfu2cdjyrz8wjxzsv7wvp": [], - "bcrt1qrn39zwwks2r2exh3ngdyxaxn2qjpgcesd620dp": [], - "bcrt1qru7x9spg7qwhkkcnarn5u0x4amkj4w4kggpcnm": [], - "bcrt1qrzryp5puu0ckfpy7f56pwaxnnnkvcap2vdrhwx": [], - "bcrt1qs9ya5hserz44elcpt992r0ycrmh3w3chzffawc": [], - "bcrt1qsjs859dp4myt54genrwngtts2hr6mkm9dxqltv": [], - "bcrt1qsprcgaldcn6v6l6jw0w6annv7hgkpw7ycxu6mu": [], - "bcrt1qt87xmzg2w7mxnqtzqle2znpva3sfgrw4hvztyn": [], - "bcrt1qtd9ues39davv39vfjkeyugzg2dxwdtcz50gdf5": [], - "bcrt1qtj8scfg0m246l3ye8f8zzjaxwtnnafve0s86m8": [], - "bcrt1qtkt4m40kh85ztdl0l7w3an9jz3u0m97aj93qhd": [], - "bcrt1qu42p79zjmtu4u4jvyeumqgc9s0n56vpl3547zn": [], - "bcrt1qu5a54g2mvz4ywfvzehk6spkd7j8m7lv4svnms5": [], - "bcrt1qu8f3htg9wrv02uq723p8xej4m4hetjq9rjd4xs": [], - "bcrt1qu9vksnt32ppwhthkw592ndjyxqwfjkr4z69gey": [], - "bcrt1quca5d5uhtucqlkkler4tnng4wcn5l96whlcqy5": [], - "bcrt1qugaf2ygav9va5yseljwajyrmfphk8xvh8l3squ": [], - "bcrt1qujh0xfgf24nr5tydjhpl5myqcg3v92hz23qptg": [], - "bcrt1quk5n5lw03adkwv9s7dva27kngu4a5amc6ggwx0": [], - "bcrt1qupdrj7vzc8a9lxvvup9dqe8qlzcjskrk5dae5k": [], - "bcrt1quykurwfx3strtkezdvvkffalncgpx85p83w9v4": [], - "bcrt1quzlcysqqmamqvn2f93vdk29rr5sj30wrwf94py": [], - "bcrt1qv34jnv6nwm8y7kpdqs869f9h99c5magqrp3z3y": [], - "bcrt1qv45z7k7c3gaw4233h60hlz60v660pau6zcen7d": [], - "bcrt1qvcd4kedmqq33wft4h3hnt40ddhdxrmcdq4hajs": [], - "bcrt1qvhrmzx3779qmfunmppqv9r50x8h09qpunx09xv": [], - "bcrt1qvpgytucyvqexr4g9lltdq528u4hep0p4pz5sfx": [], - "bcrt1qvs4zykqx6g8rugacvkwsk9gqlmj4lpmm5cyfgf": [], - "bcrt1qw8l4cqjgkrkj8dqtq8pmz343f8r0ye4aldvvgm": [], - "bcrt1qwq0tcpz8378adrxltflqucwhuacafxskljw7lp": [], - "bcrt1qwse5u3yelt0f0u4wp8umvlwqasrejqwzcwnrvp": [], - "bcrt1qwxlea0j59zl6vx54apa072l89q7zemz5fj5tea": [], - "bcrt1qx2tr9392tlw33vffx47qkx3hk8vqt07223w8jt": [], - "bcrt1qxa0vx2q4tm5pg6swlqa8ss00zctanamsjfzen0": [], - "bcrt1qxat866ff8mpun4nl4yz2m3drthmrvxg8rflmgs": [], - "bcrt1qxazu6ztf3antetrhx6hmfylpzmr0l2tjs53une": [], - "bcrt1qxh5zr72vgglpksts43dad67ra6kzxcjyj6dkq2": [], - "bcrt1qxsvctzhc4u4fnkpll5h3tklw80y3nq2ux8j4gc": [], - "bcrt1qxt7nsyr6as0ekv086x6lznkzehkq9deesv3z68": [], - "bcrt1qxx2l9lgucd3ud9tmrzj4hyn47dee62yy7y3dl0": [], - "bcrt1qxy8az7syylv5fdpfa0qjyq7qlfnkygeg3g0544": [], - "bcrt1qy02pnw9lulnnwg6m77yghn6v7ndgnjph3hrdmy": [], - "bcrt1qy5zzmrhr8u7t653qx4tdhv56rzysuvyfnhlk56": [], - "bcrt1qyakayglctv3833jyl3kuagje5qp9umaxr55nf9": [], - "bcrt1qymwjdgm74puzgwv7wpc9zhej75jhnkwlhtskxe": [], - "bcrt1qyn84az7at9zsa87ee0jjuwq3fvq4qqfuyrcwsv": [], - "bcrt1qyndrx67nc37gmznuj45r8nq7rayrs8fr2ftfxj": [], - "bcrt1qypt5v6vqs9x789zva6xf4gfafdfyc07xkh8mfr": [], - "bcrt1qz0qlzg92c037907mywmmmn2hn9gxwddygtzeff": [], - "bcrt1qz7rc0fnma6kmn83k7mz4z0lgu2czaqqtxclky7": [], - "bcrt1qzdw892rsv35xnrdeu66vylmt0tl8yfhjwy6fam": [], - "bcrt1qzqwsm0zcu7hl9h0gg9rmhexnv76d0qclskrtz5": [], - "bcrt1qzr0nluk37s3t8lgmhpze43ewkf6exladvxsuzu": [], - "bcrt1qzu5u0fgpxq5v42r62aefc2xjn6mehgzhtj4pld": [ - [ - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1", - 309 - ] - ], - "bcrt1qzx2cq4gzp0ts76pza8se02ry3rjhfdvvf54fw0": [], - "bcrt1qzxaqa7r7g8nwg7v8f5tzqv8hmezka8kv2mlnmj": [] - }, - "addresses": { - "change": [ - "bcrt1qzu5u0fgpxq5v42r62aefc2xjn6mehgzhtj4pld", - "bcrt1qs9ya5hserz44elcpt992r0ycrmh3w3chzffawc", - "bcrt1q28qtj5ugfcm4psqenq54sh55uryealeeprk6ju", - "bcrt1q25eueqeuarx3gz8l8jdascp4r456h9gydxmezx", - "bcrt1qp953wxttq5h0248sf667ef5rfqqdm8v9u5kxmx", - "bcrt1qaqxp2n8amq23wy2qs7nqjsha8c9drvh0xja65q", - "bcrt1qv45z7k7c3gaw4233h60hlz60v660pau6zcen7d", - "bcrt1qqng6p60x7zuk3yezjgh885k23fw9fltazmn7jx", - "bcrt1qdr5n0jk08cfdp67scg2v2v2ljnfxtgpxlvccq5", - "bcrt1qa9ls0ftcln068j72a070fdg5ffauqk3vvxvqzm", - "bcrt1qd20cgmpdu9ada0g0ygthl78kvmqag22kt78cqc", - "bcrt1qvpgytucyvqexr4g9lltdq528u4hep0p4pz5sfx", - "bcrt1q6vxuvwrt8x5c9u9u29y5uq7frscr0vgc2dy60j", - "bcrt1q6jjejjzq3yks4kxzy5yxpkagyehlyekyvvsdkk", - "bcrt1qx2tr9392tlw33vffx47qkx3hk8vqt07223w8jt", - "bcrt1qemzatwg2gwvznm8u5jg4pau79x2htt8wus8usd", - "bcrt1qw8l4cqjgkrkj8dqtq8pmz343f8r0ye4aldvvgm", - "bcrt1q0ma5z02ma7q0cq7j49wz66clf69xw3fq2zl05n", - "bcrt1qwxlea0j59zl6vx54apa072l89q7zemz5fj5tea", - "bcrt1qgv0wu4v6kjzef5mnxfh2m9z6y7mez0ja0tt8mu", - "bcrt1q4gcw8qp4tv9a8vmx5day4zjqgdmzf5nafvwdvy", - "bcrt1qvhrmzx3779qmfunmppqv9r50x8h09qpunx09xv", - "bcrt1quca5d5uhtucqlkkler4tnng4wcn5l96whlcqy5", - "bcrt1qhur55ueke9u6fd5vc55r5yv7nkkewgq5lcqgeg", - "bcrt1q4wle4rjunlheyrpx3cgewjry84lt9vcfn6dzm7", - "bcrt1qn69fe8fjlwyx0eqhzxl4wmypj8re3j6sq5m9zk", - "bcrt1quykurwfx3strtkezdvvkffalncgpx85p83w9v4", - "bcrt1q08atkh7p3xu5cn4azclc7tcuuv7332kmjfjjlr", - "bcrt1qaxj23v6mp5tj2wyvzh0p0xuu0jhh74vjkk3dnu", - "bcrt1qggc9a8nf9vjnjrru8v869zswhycajrsudfz4g4" - ], - "receiving": [ - "bcrt1qpm5utekdtmzwnlkh7jq5497vwwf6sm38tljan5", - "bcrt1qazle627r46apscly8lj4q5cxfxgrtuew879jde", - "bcrt1qh2c83yulvs7kgw0g6q3lkxqws4cnf0uxpcgcpt", - "bcrt1qsprcgaldcn6v6l6jw0w6annv7hgkpw7ycxu6mu", - "bcrt1qy02pnw9lulnnwg6m77yghn6v7ndgnjph3hrdmy", - "bcrt1qrmjaxllejgqu6azftsfu2cdjyrz8wjxzsv7wvp", - "bcrt1qal7npkkvathwgyjrx59ken3e0azh3ze8suwsn5", - "bcrt1q598a6uzeawe5anarp8lzdc3wkhnrewgr487lh0", - "bcrt1qz7rc0fnma6kmn83k7mz4z0lgu2czaqqtxclky7", - "bcrt1qj7ytvlc2fqkzfesnysa6zgmpqs7dtytu5lp7xa", - "bcrt1q62u2fdqsdzf3t6qmx4ykncm9s3z3lp2nmtkudl", - "bcrt1q39nz55p7yf7zmkyemft76g6nwu9qg3gskjeva3", - "bcrt1qugaf2ygav9va5yseljwajyrmfphk8xvh8l3squ", - "bcrt1qrn39zwwks2r2exh3ngdyxaxn2qjpgcesd620dp", - "bcrt1qu9vksnt32ppwhthkw592ndjyxqwfjkr4z69gey", - "bcrt1qvcd4kedmqq33wft4h3hnt40ddhdxrmcdq4hajs", - "bcrt1q53q6gv5upwc9lp6ny4yexrqnesvmpjzm8vu62j", - "bcrt1q4j7meqhavz4vz45wq2yh0sm3czl5t2n09q0s5j", - "bcrt1qjazy6ds2z9f5hdmz7p5xt334k9jr2n0mvq2zah", - "bcrt1q22ujjfckcg8yenr7j99gtam99tp94a8ayhul5l", - "bcrt1qflvrlzvxe583wp6ycv49v6zcs9a6e4cd0ctp3q", - "bcrt1q4623v9ch4qq4f6y3fqgvd255gseens6n8693t4", - "bcrt1qhdzn35wanehka6jdqftfhl52fflhr4rzeph4az", - "bcrt1qad444emwaynna3hxraedkfucsczc88fgcq935s", - "bcrt1q675dp6jvwpluld0lde7f0qs4gxuzv5ywe3z24l", - "bcrt1qyakayglctv3833jyl3kuagje5qp9umaxr55nf9", - "bcrt1qr2s35k00k875c7jhkw230rlzdvg2epvy3kymml", - "bcrt1qzqwsm0zcu7hl9h0gg9rmhexnv76d0qclskrtz5", - "bcrt1qqjxqw8hgznx5yvwca7qh6gaemxfateu96dtza6", - "bcrt1q79vkkrqqcfalzzvdmjfpd6nc69kzl92sncj4sc", - "bcrt1qu42p79zjmtu4u4jvyeumqgc9s0n56vpl3547zn", - "bcrt1qd8mjyvldlnu8vutdcd5edcfdfdkme8gw2q29mk", - "bcrt1qxt7nsyr6as0ekv086x6lznkzehkq9deesv3z68", - "bcrt1qg2dhn0uhulagdu8gqlv0mgdwthqumjnaw45est", - "bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", - "bcrt1qdh5srlj74l86tq0ltg4gs3ujtdt0jy4hmt8ayg", - "bcrt1qktdpn2ekdd9z7zwkm53nrykexw2yfj5cqpkazt", - "bcrt1qdm3u5u35ygu9p8yajl2xz25vp80utkryqeynyx", - "bcrt1qa9ezp0pcdlryz6c5weuhd4l0ty2w3wnmsppfvq", - "bcrt1qr6wwuv79thuepe2cygwn55swx943q2td9ce8ja", - "bcrt1qv34jnv6nwm8y7kpdqs869f9h99c5magqrp3z3y", - "bcrt1qxy8az7syylv5fdpfa0qjyq7qlfnkygeg3g0544", - "bcrt1qkwpwv2gkaapmdctuscj8dnjrw737ysf7efc7jz", - "bcrt1qdm5gmeakf72vlxqkuh20td3xamn57fn9rv9uvq", - "bcrt1q55mx93y6xpwuz8kr2aqvaydmyxqh2r8fmrdmgt", - "bcrt1q4pwagwjh9stnxrvf03wnd0lx3se9zrjdz2akhd", - "bcrt1qqzs4as99phwumjj29gmj8w0g4x2retcwj0racy", - "bcrt1ql8z3qj3ckpn4wkluqga97keg3e6cw68h384k8y", - "bcrt1qk5w99xz36c2q7x3h724ydwh7zud9f8epsys0yx", - "bcrt1qm9gm7h6mmz0ygwtptcmq2c7njqghfs0mdle7uw", - "bcrt1qfzjp9nzu5pvjz25lz0r6cj2upwq0slpah8e76z", - "bcrt1qgx5kpesp2c6rlckln2vrmyt3xgvkj8pafx42tw", - "bcrt1qnv945mwpkelfcff8eternqmky20c77dcpl700q", - "bcrt1qpqkpas6rxa9w9gc36d0h0h69mq24ndjzpadq60", - "bcrt1q96tpq4e8al6t6fs4u2zmvkdwtck9688x4c8qa0", - "bcrt1qzdw892rsv35xnrdeu66vylmt0tl8yfhjwy6fam", - "bcrt1qhk0l9kfwssrqnq956kcnhym46vr54hrcdua4z4", - "bcrt1qxsvctzhc4u4fnkpll5h3tklw80y3nq2ux8j4gc", - "bcrt1q0tq2hlcdtla0evqlnheulmgth36ps49cjchpy7", - "bcrt1qghmsds7h0r00nqx4m38jjn43fl6h93pkxurfz5", - "bcrt1q3psyk95gkm3qeeuppa35x80ukm8gguumymky3h", - "bcrt1qe03yytnr8c8zqdrje9hza0w60llvveam52tes2", - "bcrt1qxazu6ztf3antetrhx6hmfylpzmr0l2tjs53une", - "bcrt1q7d5fzxjw3j23vpkfcjy8xjhkvww6lytmh46tar", - "bcrt1q9ypfeksnupu5pyy05zv6uaflaqza3vvfdk9wwm", - "bcrt1qduflrcwtynwercqatssgzjyvvslg5we5e58lma", - "bcrt1qqkhl49s7zws7rrs0drahfjg08rntvm00d58snp", - "bcrt1qd3hm8xsyqc7uke6fpn0ex90phqk3zf5t7e4g7f", - "bcrt1qzr0nluk37s3t8lgmhpze43ewkf6exladvxsuzu", - "bcrt1qu8f3htg9wrv02uq723p8xej4m4hetjq9rjd4xs", - "bcrt1q7lu93azk4a39zvgcz3ps5vyxdgdmrplr5dtdlr", - "bcrt1qfjcf0sczuqey9sxnxs2wrpqm2h27lmmwuxyqrc", - "bcrt1q3wapj9c8x8lsyszsj4vn62uh3q3930a88eyx0h", - "bcrt1qdwp5jq4avgnm7ud38zz9hla8wgevc6q6r5krdj", - "bcrt1qglajmp82d6zu6fjh5aw92fe5qny9yh337awktw", - "bcrt1ql07hrgtawfttwaxzw2dqnvnfj7y3qhucprr7vl", - "bcrt1qheft2thc3ee350rx2epjxk25wlsc86f9970mvg", - "bcrt1qgzae4gqwpmxd92z40aasr78tdere40k784j55y", - "bcrt1q4tgtdu4x3y574yx0v3cc4n66agdvezgccnyyay", - "bcrt1qrzryp5puu0ckfpy7f56pwaxnnnkvcap2vdrhwx", - "bcrt1q7mkzsmw3ydmxp0env4d42s49a5fjghq5qghkg4", - "bcrt1qda4kjau4uaut6cy4y9pkcwer99ndmy36wx73rc", - "bcrt1q0tf3hnml0s03rsqfjzmm4tlnxu9k0969yqvhn9", - "bcrt1qjju6j0yjupsvg7auel7d98yuc44emgzuxvudpx", - "bcrt1q8vx3c8mqnlhymgfd27fjw3sm64j96j0zg062qf", - "bcrt1qtj8scfg0m246l3ye8f8zzjaxwtnnafve0s86m8", - "bcrt1qddhje929uxy03eumyhgfrw8q5g90sdjtnj83zk", - "bcrt1qattknz0hrh4ylxmle7ls9nnnluveltu6n7jdxd", - "bcrt1qujh0xfgf24nr5tydjhpl5myqcg3v92hz23qptg", - "bcrt1qm0q9dq7km245yrhmmx56lv8y8duzn560nq8j4p", - "bcrt1qf8cn6ymvu4k235fljv5cp9tjav7nt6cc3jl7rf", - "bcrt1qhtd4lnehrw4qstwu4dr27pa5jv6wghygwrmmpl", - "bcrt1q4kzsek2w6kg88ptrd5g2r9202fhvxuchjgwvyc", - "bcrt1q46hqphdj9u3xtwlazenlh2jja5u97er3pl64hm", - "bcrt1q6h648hj3ewwyset0qh6u40r2hexdezu2nvfhuf", - "bcrt1qdfwuj5hh8pqxqwnw8kj4lgu2emct8hutfvuupu", - "bcrt1qxx2l9lgucd3ud9tmrzj4hyn47dee62yy7y3dl0", - "bcrt1qpdrremq4qh2lsgm83cymqp7m740zawuumc73x5", - "bcrt1q5czlklraxkevcc3j6rhz7gg0uw8t0afefxrd5h", - "bcrt1qfjed6f75aqplfejyrly4jp6knj28ezqt5m99s6", - "bcrt1qu5a54g2mvz4ywfvzehk6spkd7j8m7lv4svnms5", - "bcrt1qfcpncqwn9y7082hl343qtlrp0sv5kavq677f57", - "bcrt1q9eh96kvwt6x4wrk7hwl6ulmquey2euk32dyefc", - "bcrt1qvs4zykqx6g8rugacvkwsk9gqlmj4lpmm5cyfgf", - "bcrt1qwq0tcpz8378adrxltflqucwhuacafxskljw7lp", - "bcrt1qldqtwzsy0wavrh8vddlah2jxm38hx8xpmn692s", - "bcrt1q5zmlx78xftvmz5dmwj0hpaa8v7myeu7a6pctk0", - "bcrt1qyn84az7at9zsa87ee0jjuwq3fvq4qqfuyrcwsv", - "bcrt1qdnjd24626q4av9kcl373gjml2wlx7qrwhtndpp", - "bcrt1qmxcczs70me327dm7a3uzcj3ccp7tpsv3s025xv", - "bcrt1qf37fqcgrv5hhs7cw6qsge62unnzhptgjef9yuc", - "bcrt1qzxaqa7r7g8nwg7v8f5tzqv8hmezka8kv2mlnmj", - "bcrt1qnupz7geuaqcv85fcv03aecsxk5srxyru672r76", - "bcrt1qdhd6dytgkvcf0gjqkuccdsukn5hqrtwgasvuep", - "bcrt1q2ephrnnnl80je2jshfqwxszynrv8lkvlx6nntg", - "bcrt1q9hjzp5hkngmnn3e58xxrvqs9npjuxtfwfqrerf", - "bcrt1qlru6t7c4gr6htfps9lrray6lzu3u38w0fmt74n", - "bcrt1qqeauxxl0gu7atj98se3wrtuvltdu8wchhjvp2h", - "bcrt1qypt5v6vqs9x789zva6xf4gfafdfyc07xkh8mfr", - "bcrt1q0wjhd2h4t6ze0jpnzh0cxkg4sst5djz8tjv5e6", - "bcrt1qdtf5wkftsta3g5l93fv2rmdvcnlnhzuwjlpse2", - "bcrt1qj3kdllghxnks02mjksk33q44ekq4xax9r2a2mq", - "bcrt1q6uhp3ygh6xz9jd9rspsnl2kumec6a546d63s2v", - "bcrt1q3yhmfm0sx8r3t8g4tmu78ye56zl8kpqplx0nmv", - "bcrt1qhk923893hrl3f6a7dcxsmnuutlf84x87djqy3u", - "bcrt1qjugc5864qys7hl4hsall78yu62js3epf9eqx3p", - "bcrt1q8n43787xqpuuzepj5u7as307lvwp6vnn9rw76y", - "bcrt1qxa0vx2q4tm5pg6swlqa8ss00zctanamsjfzen0", - "bcrt1qsjs859dp4myt54genrwngtts2hr6mkm9dxqltv", - "bcrt1qmhgnycwt2zw3kna6qq9dw4f88rn3qv9f2zks2f", - "bcrt1qy5zzmrhr8u7t653qx4tdhv56rzysuvyfnhlk56", - "bcrt1quzlcysqqmamqvn2f93vdk29rr5sj30wrwf94py", - "bcrt1qnf62mcnu7v8vevdukxyqhnk5jg285c7fpkfjgs", - "bcrt1qad5z7m5w6fqn43etjr4c8fkcd2s87dsmkkkk5v", - "bcrt1qwse5u3yelt0f0u4wp8umvlwqasrejqwzcwnrvp", - "bcrt1qqjyhj6qlcm0u9hmj9wvdhr3kmafx9wkdlrehz6", - "bcrt1qgpew0excsv7wwfqfqvysdu3rc4x5jzrmwz06cq", - "bcrt1qyndrx67nc37gmznuj45r8nq7rayrs8fr2ftfxj", - "bcrt1q3zc3uh3rjj88zn402u45uq7vqqcesf02esnw3t", - "bcrt1qmmtewyxx5rrqaayr5htmvft7a5w5kcaeswyku3", - "bcrt1qn8pnsc9mhl8xhjmqlt74dy5nk4n5v9exg63e24", - "bcrt1qmtpg4kh65cye2r4mwffqf2eq37h4wpnze6h6wc", - "bcrt1q5e5sd3szwguue835pqx3zph92zym3zjs9qargr", - "bcrt1qnzc684efa9sg3nrvsdqgkdh6zwpvfa8ndm794t", - "bcrt1q5jtmty5pyhgq6cer4adqgjfukvzrup35k6eavu", - "bcrt1qpfkl4cd2txk2n94h9cqvamp6yhzd2c496tnp65", - "bcrt1qru7x9spg7qwhkkcnarn5u0x4amkj4w4kggpcnm", - "bcrt1quk5n5lw03adkwv9s7dva27kngu4a5amc6ggwx0", - "bcrt1qzx2cq4gzp0ts76pza8se02ry3rjhfdvvf54fw0", - "bcrt1qmhqd4zyqmvurgjx43zrge562k7thnhtufk26w0", - "bcrt1qecqclzx9tsd9q2lvfy9vumf2seht5tsxc335tm", - "bcrt1qn6erz7549527kdfakz7fl460rx4yqfy8dt885j", - "bcrt1qndy4dnx4888zr294q205fxyqqwvsa9ndd9gtn7", - "bcrt1qnan56c8ndmdu6afzqrlmkjscklh4dceyrc6czl", - "bcrt1q9z0w3qn7j7hq2da6fuyjrt5tjn5edyytnz45p7", - "bcrt1qz0qlzg92c037907mywmmmn2hn9gxwddygtzeff", - "bcrt1qj2kvwhrx3g768enlv4jukstffqkd54q45y6a7t", - "bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", - "bcrt1qh7kz39tg9csrlq6w8lfyzkgr7zw73ake5g36zk", - "bcrt1qagkfjmgxal4ewa0n0uxcgmsg4vvlag7vrymcs9", - "bcrt1q5kcw579e5cy2xkvkv3725yjqjtz65t8upmka46", - "bcrt1q953hr33ww3d5qspq6haxe6gj4djm3atuns9kas", - "bcrt1q6k48erknye22qdnae8ajg7rjl5mck986557zcq", - "bcrt1q0vsaz2czkjppru02e3mgqc696g925mtmkmqz0t", - "bcrt1qafme7ruwyqcrft39e4c72tq6sxky3r3tl7s69y", - "bcrt1q9s5wudq6tqapdhezejm2uxek6f84jwx082l0kq", - "bcrt1q6sny3g2erarczmcpnd7clc8njdzqqzhc5y68wg", - "bcrt1qxat866ff8mpun4nl4yz2m3drthmrvxg8rflmgs", - "bcrt1q895s7qgvkmhq4vqcpcqar20kkdqh4urtmx9e0h", - "bcrt1qcv3kwkf2p6at0x9gtm7fumdyv33wlfys727z6d", - "bcrt1q6f9dr757kgrknaaygmqkg82h0apvnz303p8x5e", - "bcrt1qpcjaq6ajxm89d8ff49pvvf3uus2ulkzmzdqhr0", - "bcrt1qtkt4m40kh85ztdl0l7w3an9jz3u0m97aj93qhd", - "bcrt1qhgvdtm4f59q8wd4tp4xsd9y4sa6p4vlwyqeq33", - "bcrt1q9pecdfdkwqjcn7wt8ggxtx5nq2e0hu0zwqr5p3", - "bcrt1qcmlhh9n0q6ksl0yqyt0ujyg8dajvpj08mpgfzx", - "bcrt1qf42akvcychqwv7gax2lvtt8d6snk6yk6j9dfpt", - "bcrt1qakp5mpe00cl4a2k2kn79yjwlv58xymaxwft77d", - "bcrt1qqa6f5ueq0ke6pk2dc7rfqxmwnl0pctjk3qtafy", - "bcrt1qxh5zr72vgglpksts43dad67ra6kzxcjyj6dkq2", - "bcrt1qm4932l4l2spedr6fgf7kh46975t3xgj87trrwj", - "bcrt1q7hlkjye65lawffksuc59lwp8246g2lfeq6xeqh", - "bcrt1q53ujhg0nuk30afuqdvr02lcy73g6397pffpf7u", - "bcrt1q5a2zqf9tdhcv5x80fp0re9fne8uv2palye4adw", - "bcrt1qp3txzmnma0tguhfaqm503mrusg6hx2j9ltvhxw", - "bcrt1qtd9ues39davv39vfjkeyugzg2dxwdtcz50gdf5", - "bcrt1qns77fmn370m7e47kxkfxelt4av5jq5qvmawvu9", - "bcrt1qmzx9eh5lljf4edqz48fe7gktndewvpmc6h9q0y", - "bcrt1qqemhw0pts3j5yxktm66qkkugmvhc97nwh7ys7h", - "bcrt1qpxrshlss34900q50yadm7he9lulhg3quu39jja", - "bcrt1q68gsfew2uccu743dtgwg9gf74k3zs0va9nyfg3", - "bcrt1q2c23j6juuw5r6ryr59ew5j5xnfv5e57qwlprz7", - "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk", - "bcrt1q97dhvm6mpxe9jehele2d7gehrgq4ruxjevaecf", - "bcrt1q0k6rq9xny2jnk9z95w9x5zv8vkkc25d8dq2pmz", - "bcrt1q2ds58hh500vl9f2sepudvw5d7qvj30aqly6vtp", - "bcrt1q5y9w84tf3xfxss6u6eueejmk7pmcssnz0g5hzp", - "bcrt1qt87xmzg2w7mxnqtzqle2znpva3sfgrw4hvztyn", - "bcrt1qymwjdgm74puzgwv7wpc9zhej75jhnkwlhtskxe", - "bcrt1qupdrj7vzc8a9lxvvup9dqe8qlzcjskrk5dae5k" - ] - }, - "channels": {}, - "db_metadata": { - "creation_timestamp": 1770127963, - "first_electrum_version_used": "4.7.0" - }, - "dont_expire_htlcs": {}, - "dont_settle_htlcs": {}, - "fiat_value": {}, - "forwarding_failures": {}, - "frozen_coins": {}, - "genesis_blockhash": "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", - "heirs": { - "aaaa": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - "34%", - "2d" - ], - "lucia": [ - "bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j", - "32%", - "2d" - ], - "mario": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - "35%", - "2d" - ], - "mario2": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - 40000000, - "2d" - ], - "op_return": [ - "OP_RETURN:48656c6c6f", - "0", - "2d" - ], - "op_return2": [ - "OP_RETURN:426974636f696e2041667465726c696665", - "0", - "2d" - ] - }, - "imported_channel_backups": {}, - "invoices": { - "3533122da9": { - "amount_msat": "!", - "bip70": null, - "exp": 0, - "height": 1371, - "lightning_invoice": null, - "message": "", - "outputs": [ - [ - 0, - "bcrt1qdnjd24626q4av9kcl373gjml2wlx7qrwhtndpp", - "!" - ] - ], - "time": 1775397793 - }, - "b6bddceac6": { - "amount_msat": 500000, - "bip70": null, - "exp": 0, - "height": 1047, - "lightning_invoice": null, - "message": "", - "outputs": [ - [ - 0, - "bcrt1qk730r0l52t870lhr7v2ufcvje7rugxtg5ahxd6", - 500 - ] - ], - "time": 1774656210 - } - }, - "keystore": { - "derivation": "m/0h", - "pw_hash_version": 1, - "root_fingerprint": "594b3406", - "seed": "evidence pelican accuse educate weather state room ahead shine arch run sample", - "seed_type": "segwit", - "type": "bip32", - "xprv": "vprv9FtghFWAfH3BGFZESZRHqbVfHiD51fsJf8bjyCsoqsSdJAdvrTwzqWZLVTP2tUmg4EsCZ4LBu6FUkyPQoh1fejiuQdM55d5Z59oGwRZ9vWx", - "xpub": "vpub5Ut36m34VebUUjdhYaxJCjSPqk3ZR8bA2MXLmbHRQCycAxy5Q1GFPJspLkJywJjBgQnvU3rmwPKTPp1ELLWeXrve3zBufpZR4MRCCTNHzsn" - }, - "labels": { - "0013a409b4f2c372323a6d4ecf16dbdf209ea40bea5e7768abef02096e422295": "BAL Inheritance transaction", - "002ca75467176b550c73aa28bd006c32a5f7c9db3b2dacb8641b76e9f073fe72": "BAL Transaction", - "0052d66d58774d7249c5123eb04c6e93d1f3b81b95f0a147e7776d95a89df1f6": "BAL Transaction", - "017c447f838ac50505362726581a735df7ab031d632c0a5238b717c54e23d966": "BAL Transaction", - "026cb27bb0fd7713c55192f0fa544cb0f6c4a7a7c8b30fa1e46d19203173b758": "BAL Transaction", - "02c18d4182190f62e7783f62b3333761d5ae70754515f16011b810c25f92989f": "BAL Transaction", - "02dfb7815efabe1183ce34908d4a6e6a3f25495ad5f57120640843d418479e7e": "BAL Transaction", - "0342999661ce320082597a64a4f57ff54412c81c9e0d8c800500316d608c73ba": "BAL Transaction", - "03962fa5e7f6ffa884a79895d689ef70e9a74b279b5ddb187d0fe6b49c86e934": "BAL Transaction", - "03f79e96b8020af16c5fec1e99e83f37b436908dd8c18163ef3eff442e609b29": "BAL Transaction", - "04a14aa60b6ef0c727fae209070dcb7926389aea0ef643ceaaf788c2c6e8ea1f": "BAL Inheritance transaction", - "04ce14acc1c38c9d11f6d4b6488de7d66c0994b0b94563efd0b8bf89a96c5865": "BAL Transaction", - "04edd61439362b3c67fcb1ae0e09d5fb145cbd1a8d8d574d24d5231bd65cfb45": "BAL Transaction", - "051f5bb1b0df3f8e5011e71449646a1c6595b4e41a0252afbfbe92489a6c5043": "BAL Inheritance transaction", - "059e0b16f98397641a719e4fff0b04967843c09bac1a3d20d355a9637a1c1983": "BAL Transaction", - "05e515b695607b8b8c9f49a688274c19997fd3105d37878e404d10cba621a447": "BAL Transaction", - "068aa2bfcaedf969158cb8c9af17bf7c6e0300459636cee2d4a729cd806b1218": "BAL Transaction", - "06e6988d343652e5c6c9bbaed4afde5977a08236f7658dd5f35d9032d3a34bce": "BAL Transaction", - "072389d26ff2863eb1095739461b681c25a28b6685f01dd5c3bab1ddeb42d22e": "BAL Transaction", - "07e4df4fdfae66ae6c638e237348692aaec54782c31f5d610f4b605024fd053c": "BAL Transaction", - "083888d971329c5194c27fb2793bd19956e6cf325258663ab30d55cc6b9f6d34": "BAL Transaction", - "0890de4dc4d8b0790eb32087cb2b6b19d4558193ae00c2cfefe697cdcd79ae85": "BAL Invalidate", - "0974a018118d7270ae2342203c89077dc7db886071a1d9f5a2ad2253658dfe24": "BAL Transaction", - "09ed4f4f5eb0ac2dd24d713a43d5ba0d6b4d327c238e55407b960bae1dfcca55": "BAL Transaction", - "0b0222b81f277d1e857d120505f89e09bbcfd0f716a393db08971fe15b0d894c": "BAL Transaction", - "0b09fffff7bd2a731aa998cf4f9361b32e97d43be2259d6ae00a7f0415157f5b": "BAL Transaction", - "0b111ff3ab8731f977966294650d416cbf964a30374ecd57ea4d84722177698b": "BAL Transaction", - "0c2567df728f9bdf1fca20e3d67197d207fec61810266abd104724b4ed4c4eec": "BAL Transaction", - "0c8ec95d6dfa32a4fe6d141e38fb5da83b198cca2daa8487fbcb7b9dfb41ae7c": "BAL Transaction", - "0d4092a0cbaed21ab7f44cafb9acd50bdca13fea7483236a197fcd47748e89d1": "BAL Transaction", - "0da9928d802686fd9365de819a462f544f2536b72a35eebc9c10e70a96c05ec1": "BAL Transaction", - "0dc19e99bde488621d5d921688f0d907a5dc6e3bc058c32a8b4dded76e48e6e7": "BAL Transaction", - "0dd68ecddef2c7dda9505f431f7deee8ecc43dcafbc1f8a0c74d009ac37c5044": "BAL Transaction", - "0e4514268c434c1ab0d6e25b4d4a8d8fe5e2344701c72b21b7c30c6b5f332ab1": "BAL Transaction", - "0ecc8822fc6402c6f1a07d62c2694bbd6029a6c732118634c1743da12ffb3b32": "BAL Transaction", - "0f19080f3488579430dacb94e6af1ff177babefed3d2f8e15636d0f9c0e8d629": "BAL Transaction", - "0f864bd74f0a66410e4c251f0993a61837f01ca947ce1badf0bb57e4b38e6866": "BAL Inheritance transaction", - "0fb9da0e14c71d8b85bf00d23fa3b599972ea48f94e567e0f9733ed7357ba686": "BAL Transaction", - "0fbfe13e0aaeab6b4032f4f61f15b23efae98a273006f96c6a53739f4c4ee4b3": "BAL Transaction", - "0fe91105e4dec1a6885f357870f8a4c12ab4ff3e1eb44bf50c1e31fd7df9dd93": "BAL Transaction", - "10617202b6e0856c5b015e1f882b7ee51ad7edf85ab1d0f7764b5e8eb70775d5": "BAL Transaction", - "11a7d226afc8d5fe63f9cc00b7edb5a8e4dd8444e08930d1bbe77e8d14171cd2": "BAL Transaction", - "11b6ce930c0c09f264cf9ce9ee5ff174c1550673fc65cff3d3cffac86ef8ea8e": "BAL Transaction", - "11e2628fbdf54a7484b99124b2bc8f9ade3549d2eedcae72dd42cded7f9ac4eb": "BAL Inheritance transaction", - "1210e8189eed90143785def33b65240deab8b2c051526a267aec313762ebe295": "BAL Transaction", - "12664cab862cb5c1c6ba1b4d44a79e0c23ce4410c5685d578585e01a29a4c7f0": "BAL Inheritance transaction", - "12e8be720f97a0e449e047d1eb3b8b5700d47112737c3acb894a98e33fd3598d": "BAL Inheritance transaction", - "13998d34fcafd09ac0740f728d0381233e9ec59628785e8e3d4ce79e59232ac7": "BAL Transaction", - "1403ff43f52ec3cf73892c429bb7c0c913814b01f87fff5d71f2f10f240d6dfb": "BAL Transaction", - "150945af0c56e570acd35c4c003f074c775d20aafc2f6a20beaf380f5e3d5ecd": "BAL Transaction", - "152f2c51c58697507f03641069d3100923f06bdc77c10de9b85e21b59986d458": "BAL Transaction", - "15c42573436162f794bd007039f5930cf3fdd3bb8c9362840c127f7b398ff337": "BAL Invalidate transaction", - "15f7dda988d4171681de6892a19934c81d86121f20fff6f9b3a00c1f4174a392": "BAL Transaction", - "1623f1892736ae0e057b43760d813a82f7a6dbc39ef1aeb050dc0176eb89c26a": "BAL Transaction", - "172dcc6519f6381112614ad40e4b743f2cb7b3217ce1f3965539c83d2a9ac773": "BAL Transaction", - "1822f216931aed6d7185e5961e99c538903fb71d20357634388eb6af0a256fd2": "BAL Transaction", - "186ea0382e86b35527d6f31c15fc7fdfd97912d14a181274b26646bef3c41dfc": "BAL Transaction", - "18b043b4c2a977ad523fbbb1b78d76ab89ce92194f5c5ef7d3062e8fa579c4b1": "BAL Transaction", - "18e74102ab46fce7993dde778e1caddb8dbb3b7253d4a4bb345f0026974b939a": "BAL Transaction", - "1939d2d4532465efdb92701428b2eb6eeeabfd1c9824197c9ae6b67b535bd5f1": "BAL Transaction", - "19cc2e3dcb7c40992e0c3869d8a177c66fd04df8c412c473bca32fd167f907c2": "BAL Transaction", - "1a639c7a66d0af76e77ea334fd2988fc72bf828062590c124f2e9abf36108061": "BAL Transaction", - "1ab1943b449e50f6ec2b2f4bab1fab258d0e09f99394c1032e4788fce6157dd5": "BAL Transaction", - "1b55ea762c9a6e86b8cf0498360f43be53b608d991467f29508cf2f3966054c8": "BAL Transaction", - "1b71a1534e64f866c5bb2b0cacf32c3feb0a86eaa500bd473d3f3364f5b7d433": "BAL Transaction", - "1c414575dfec154644890d9c3213946e2bbb35c50b4f66dc43a9e812bb398286": "BAL Transaction", - "1c8eca94fb592f3d32e8ae1227dbb440b0e5b58265316a51ff923b245bf7ac61": "BAL Transaction", - "1cab7dc3302ecdd5141f0da3e75a68e060e245bd717473d230171e28c45c7fba": "BAL Transaction", - "1d9f99de53b6326da10de7e1514fef795bacb48343f034961337d27862dcb21a": "BAL Transaction", - "1e4d59e4cc5b7cd10be6624dd46bcf90cce2fd6320fbd496a71117cf82233df4": "BAL Transaction", - "207815deadb6a33030d1d9285cdbad191347aa2f73a2d3200e94276a894fc978": "BAL Transaction", - "2098a1cdd003b35e90a89277d52d9fdc121698f6e23a75584763280da145de64": "BAL Transaction", - "20d60b3ee98e2349bdfeec080902afe333efdb654b91ea15b1be0344aba8af66": "BAL Transaction", - "216bec5968fb1912a68def9f4b29e74208527679e0c7c252ed8f2a53d6e2cfc6": "BAL Transaction", - "219c654a7f3b9fb187749b58662423ff837d0c6d835c72e764dec3e07c2ced3d": "BAL Transaction", - "21c5eb8e07a8d660b0b115a685f95ebbcb80804900cf44eab89dd77ba734cff7": "BAL Transaction", - "233d76f504a396e6db3ffc4b34dd2a5e83b8c5fea2ce7eb95da19d97b355c4ba": "BAL Transaction", - "2454a441c9b0443610bc37dab45fa23de5163ea614b6ec7163975926e3026838": "BAL Transaction", - "250e543bcbe1ce524cd7c439cf620e3921d98173ead5d582cfddded0a2337bd7": "BAL Transaction", - "25cfc75a8082591522542750a28f3f696360e3497cff596bbe7e20c50310dde2": "BAL Transaction", - "27674bbe4485cb5f4d869124814f6d643201bcd13599b60dcaf693550cb0cdcc": "BAL Invalidate", - "27a70f96b10b49e4dccccabc27d6b75516bfa5779ec53c3aa02cacb6d7c2c39b": "BAL Transaction", - "27d8bafd4f7be6a086af8ffc6ce0fb50588c035669582beed48538032bc7d50d": "BAL Transaction", - "27e579cf0329d3a7ac6ee3cce36de4532911829d71bb2c6bb2c17d3084fd4326": "BAL Invalidate transaction", - "28b432660bc1232de270461ec9639c07d4438d77f0c2571cede1f92351254a9a": "BAL Transaction", - "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d": "BAL Inheritance transaction", - "28e0ba1ecf9c349345c67317a15fb7e53193dfb1657170b32e4dd8354261bdab": "BAL Transaction", - "2967d7c89ad9c988524978a11d5fa51aa16269049c57dce4d01baba990e2d95d": "BAL Inheritance transaction", - "2a4c51bd0df1e37bb015935fa206b6623dccc10c1f11b3afe1ca5513e2be0189": "BAL Transaction", - "2bc2274bd279d0f763ca87fbdbf8d9886df829e198128e62ada514f936704a16": "BAL Transaction", - "2c7d1cc63a5abcd7fd206da9523a9d8b27e1fc2393288bc1b727870cb2ad5e6d": "BAL Transaction", - "2d5da718d7bcd98bcef14d92ddd3e36ea20c1ca82f7f0deac9beb82610e61f78": "BAL Transaction", - "2d9e6650030b7f3180d18703578e7a23736342c7f48f26f8b9d2cd7151e8cae7": "BAL Transaction", - "2e3aac83fc742ff0ca2457902c1e1e172cbea223d1bb114ac140bc27c7d1760c": "BAL Transaction", - "2e53015791cba5b2be4d32f48702310e4542f0cf7c16bb0143760f23e5d2e164": "BAL Transaction", - "2e6310e54515b3f8015032f2adc1e203022303dc11015661694b4ce94eb653f0": "BAL Transaction", - "2ed95ab6bcf71b6fd9c6f81840654fb72f19ab945019f265253bf7414ec57496": "BAL Inheritance transaction", - "2ef8ee4b5a0c39c0ea5f590551852513766612ff908bca7118a289179df1085d": "BAL Transaction", - "2f56e55e487ed0efdb0129e54c8adf20d191b90893ae9e31f54ea5e97086f506": "BAL Transaction", - "30c57bb2828d8fb85ec366a1642093318fd3d4c3e8cb69f90fa15ab3d25c633d": "BAL Inheritance transaction", - "312ccfc8805e5ee460bd7e7869ff4cbd1c2b9737285d8bda7fe8d33ca8b1a363": "BAL Transaction", - "3137ebf306912e0ba0a0abd277968547c28162cdd7e1826cc61b9844124708ac": "BAL Transaction", - "3182535e41faa7aba960ae293d31de27f68ecddf72aa36cfa4ac0c3b7c0de3e5": "BAL Transaction", - "31b7e1809f18d667eec17540f76ce6d7ee11e67822a71b34797e9e3c6cf85769": "BAL Transaction", - "322f366dcecf411038fb423afb3d07609a03c1a3bb15976e68727acedc516bb9": "BAL Transaction", - "32501d75a0d73b52107c109bc512ee9d1c8014409dd92514de559329c15fe869": "BAL Transaction", - "3318599c8ac525d9f9a29c298a943ae887afbf31ed92b781ceaa547e3ddd352b": "BAL Inheritance transaction", - "335d73d9f9295b283a07187652a587830eb1eb2ee2829ef566dde8445946e474": "BAL Transaction", - "33ec7e9429ba79b66cb0f229e4da8233a3a2915020bf3da9c0dae53368a560e1": "BAL Transaction", - "34b6a345c07a9976aa39614ec2dd40205c9106c3dc37db352e3fcb3c85c01548": "BAL Transaction", - "351fca4ba3a77804555cb64ce55ee66f438312ac0a2227fc7da0d42b8606d543": "BAL Transaction", - "371827c7b8856b19d19a65d8b232edcc12d587d35032315b9fd0f0c9ce509b5e": "BAL Inheritance transaction", - "37ea7823022f3d4fef0cb547b8eaa43049d0cde7aa42b97d5d6a13e04228bb35": "BAL Transaction", - "37efd437f8fd4632c996a8308615ba647fab4e407412a43a2ac86822c22e5487": "BAL Transaction", - "386f360a493c758e12a9130ab6e4ec333dbe08e1146c5fc05a61671ba6305f03": "BAL Transaction", - "38c9bc0ed52fc0ecf4bb7651e419430660ba81d4b3d0be32fcf559e618ad0bc9": "BAL Transaction", - "390eab9d9941d4052ae40e82c46dc4b558e62da306193ab143ad216df72884fe": "BAL Transaction", - "39ba93514ca7010bb66190c40a4797b237063defba7fa533c516fecc71c6ad94": "BAL Transaction", - "39e81311f9df5e96524f1dd177fe48bd23f6ecddd28909ed426bdc520c8c7947": "BAL Transaction", - "3a287fbeef5e60d4972481207ef4ae1c7978ae901306c64e954fb4eb8a82c123": "BAL Transaction", - "3a7be22df3fedc7eb458c327463be69697b0da6f96c2a0ef1faccc5bae92ea71": "BAL Transaction", - "3b0200a3c484853190b75133da6cd6937f7ae79cfc817aed409abf2b2156c461": "BAL Transaction", - "3b434923da26b1a2b47c1fb79ed8be70b3041d1124e94d5f7576cd060481d6b8": "BAL Transaction", - "3b476e51b54ad537ec037f54cb22a585d25f2933830848729c7ab8d259ade41b": "BAL Transaction", - "3bceb17f404b15b8f34eb3dd9b59765346918dde8f5c817d8d3b39c4f08865f8": "BAL Invalidate transaction", - "3c2748e748f7516d00212069d009bcdcf25f76bf4a603aaab9269831d18b1a37": "BAL Transaction", - "3c611bb63d8167b25a1f90af8144a0079dd2e7b2047e0e1222b48a02e38fc4a6": "BAL Transaction", - "3db084df6b70dae8f5d5dae65d9a03080ea24078523b5ad7c6cf6209c5768c69": "BAL Transaction", - "3e14d633a30aac5ad3fa7a0dba835033fed9cb020b1d2f25172d7ac8f0f30be9": "BAL Transaction", - "3fc59d67b1a1505c54e10f15cfd2a7f24c165127fd644ac5345db83ac11e6d90": "BAL Transaction", - "3fefb95bebeb8075fd97dbe770e91ea9131f7ad401ecf3d48a6e7f98efcefaa4": "BAL Transaction", - "41e4db32c6457c93f853d452eed770ba23c78a3127f620fce080af0ddacec101": "BAL Inheritance transaction", - "41f99fcc6acd43b3a7a78819ab5ab3aafccf3324376a3d635f73f9719833b8cf": "BAL Transaction", - "444fc6b9d70ceaf44558e815ed5483f26ecf2660815e00a14461ce3da8f922cd": "BAL Transaction", - "45667d80a6a7e935664d961527dac643df62487d96d2e4ecd3a03aa4f59502f4": "BAL Transaction", - "466ad9ce1be13f55bea767dd54f22b6344a6a4f325ba5dc89ebba2f9bdfcd44b": "BAL Transaction", - "46e66f103b58d50d142f384919dd83bb1a3bb15a50409cb054e37550751913f8": "BAL Transaction", - "4771c0949025c0d12727dd10a3cd4e052ddcd9baef6844af5bc3136b457a6591": "BAL Transaction", - "47c74958267c4f0a86220f90c43308c12f0154972549d889e937485f5f573865": "BAL Transaction", - "48293517be6644ee9efb643a220f89e45d396a8890b694cec3b3ee8cdbd85d0d": "BAL Transaction", - "48a9705225ac2acfdb8729fefeb3a7dc903d59d352877c6598c24c736c056707": "BAL Transaction", - "48f13cbcbbe4ce904a96c1fc78f063cde0e2b70fdda73d0f9137aedf7dd0ce42": "BAL Transaction", - "497f0ff47305a29e42aba62b98f15dab44638c30d68d60a8f195970a964aa2ab": "BAL Inheritance transaction", - "49d611bdf156bc19df59502a466a0c379f155d4c9d7509917faaf9f9ba67672b": "BAL Inheritance transaction", - "4a69fe3090651e607009ebf0dc537323549d8af2c02b40c4e6345c799f72998c": "BAL Transaction", - "4a8f43a9dbe2b02a401bb40696eae3cb1ff0ca23b375504c50aa8183d29b97ff": "BAL Transaction", - "4acdd97a874b986907ab1ed97e4ecbb30503a30556ec3578aaedf04656cbd40a": "BAL Transaction", - "4e1294eee9a4153043efd8908fc589691e2af0ebf761f1449ff4106e7b6cbaf4": "BAL Transaction", - "4e8db01ffc884db9cdada521f3251a804ba47072036d79a84780fd69c04556d8": "BAL Transaction", - "4eb64cc898cecca0e1ded67b5ceb0ea843963e32185656ac88a9b441434c437b": "BAL Transaction", - "4ef924ff563990f20acf7a2b750e8f701675ba3ab8baa8e48217c120616b7ff1": "BAL Transaction", - "50807ae80dbba0597c5367d2bb8a4d2a54c99834f1965e73cb098720538115e1": "BAL Transaction", - "50a0dd91c7c808b1bd809f0783a29f87ad8e3514958e23310fb8b080935cc058": "BAL Transaction", - "5108e7bd3145811a8b29a17563f51f0250023499aeabd7b7ef01e5ed7d1177d1": "BAL Transaction", - "517290afd8dd6660e0dd0d3104678d6eb3028888436f4fa6b0c681e64d76607a": "BAL Transaction", - "518bb9194e55addad087cc7ff03d93b0879cb202a9e34db4d222a54e1fec513f": "BAL Transaction", - "52a4146f8ac2ea3e3a729528df3feb5c4d0c9bb20fc565a3f3964fddd45cd7a4": "BAL Transaction", - "5475afc0f4181fc9af4c8c26e0c2542182281a2bc4f2386759bbdcae956eef5d": "BAL Transaction", - "54a8ca1721d9f2c39c4f90afc7e0d06e9671c0bcfc25100777e9c0a96683a52d": "BAL Transaction", - "5546f9d8391ab97d8cc8afbecdd0f029fdf38e96bb8e2d30518d2f59828a3e4e": "BAL Transaction", - "577dbf5a6366d82a25ed551d31ca096d6f5b1bad6f4895a4367a79ca63f13685": "BAL Transaction", - "57fa547b447a5116fd7a226100b87e2af8b41de1d3985d990f0ba2c9f61009c3": "BAL Transaction", - "59305397648773180020d9e335477214bd3e82b063824154873bc2e122773a8f": "BAL Transaction", - "594422e5f1e1e34188852c9554dc166abfa130704df9146c2b01b4394cd4da86": "BAL Transaction", - "59b72725ddd78643b55226378434c7e74108e83c8663c19a4c5f08bc144bab55": "BAL Transaction", - "5ac7a115b32da8741e77f70a6137770e758568a469e5d87069ebea6da38cc300": "BAL Transaction", - "5ad9f6c6b85b24f35998bfc0c4076c6750063e8c1e708afe31062a04f7d8b72f": "BAL Transaction", - "5bd43b20a2250e055aa9db1a3df7d22af0ed8d63df946f3ee47e354adef95ec6": "BAL Transaction", - "5c99f6dac7d310041712f3ec950a21f1bc55f30b44aeb7ea80561e90fa907500": "BAL Transaction", - "5cd9afa7502e1eafd488a1d467ba313f82f0e4f85c2f66bc47e207fd7f0cebb2": "BAL Transaction", - "5ce78331de95ec851dc8ffdd213d38e314820ebfe641b5427fa9ed8d12ffa45f": "BAL Transaction", - "5ce8e0787b35aa8b36a515bb77751c680b06d717dd266abdfcddca712d75bc65": "BAL Transaction", - "5d1fa7991d89863c212e39e886ec3ca0f390b01e9461181d87042a59561d7391": "BAL Transaction", - "5d88d33cb04b54f400c2b9492a1721bba80947d28f14e9a47ceac47d7998c175": "BAL Transaction", - "5e9355915428b150861ede28eb86cfd17692e063f495657c3fdedc267a95e20b": "BAL Inheritance transaction", - "5f3642df74775c62a4d69509ee213ca40c366cdc2add79d7108613def88dae29": "BAL Transaction", - "5f8c84f762867a340f7be5577bdfe09fced800b44c5a03e92f4092440e9004f2": "BAL Inheritance transaction", - "6008b300292d3e94fa1ae08f9034b80cde6c128cacc91b61c5f0edb7fb72b981": "BAL Transaction", - "604bb78f6af7e9f29194d0ec6c488ee0f83536736152f8d9e2dd1e4246f55d54": "BAL Transaction", - "60b972d2dd0bb3a32eca07888a86457bf12eef536f189344047def45b9accab6": "BAL Transaction", - "616bfd8959d8a56e548678ef763656e638ca383c4e83a8eb1c97e95aad9904c7": "BAL Transaction", - "62850279ecd100a1277cf7f438caafee569cbc902193d22573c20000b4834ddd": "BAL Transaction", - "62a15e486c7a82cd2df893d45d98a4a9844d2c5afdf1477463b33d16a8c470c6": "BAL Inheritance transaction", - "63997a9cf026755d8c1c4ae6a0252123bf7f262f347d7b386c916e30083d8133": "BAL Transaction", - "63ac0dc55929ff8d65ad42b4c93b7f489863d5c993c23f600e270d656e448a64": "BAL Transaction", - "641c271d8afa23eeb708a2b517640992fdcca1f6554cb68862f1e9237404b68f": "BAL Transaction", - "649088a6e3629a72aae112903c62eab2cfb4d89cb824e99f66320b1dbba5c258": "BAL Transaction", - "66627bf44c45314fca32195df8dfa6fa6a950a6f6492a2192d18f292b5c441bd": "BAL Invalidate", - "6672bea8ae29f1b2fce4fcedf95ad863f69291d5ff2d8e2c3a58d9d1370d5ebe": "BAL Invalidate transaction", - "6698bda498953e55b81e114aee06b876f547225d90e388dc4327577e591e3e26": "BAL Transaction", - "66a8b89e1d14a0a4d100740f152bc187ecb28c5c6b38a5a4137f53531f362439": "BAL Transaction", - "66e35bcf24f86540d0d8f0bd12706ddab93727ba7dfb63834f9d1537b23068c4": "BAL Transaction", - "672897a3b01cc6bf811d888059d9cb3f75e376a21d639ea594b973c2258236f6": "BAL Transaction", - "68a0681d65630225ff55bbe3e54e8c62191648e0061ea491fe2760d35d460851": "BAL Transaction", - "697895649863c50517092f62b47284ab00dd812a100a2ca6ac9469b5e4f6da2b": "BAL Transaction", - "69ef8555a550b86b79b0cce11dc9464312be1d930db632dc837669553380d4fa": "BAL Transaction", - "6a8a9308b7c7aa75e469f3c34de88383e34a50168c681b65b48b28a3e9692fa3": "BAL Transaction", - "6b2a26c99208098e6c506d11f5e043866659cab15c2070b425ab357e67ec7875": "BAL Transaction", - "6b3aaae29bfd8e48b5639dba4c12b41f7e6dfb46fc89740f532977f786c16c8b": "BAL Transaction", - "6bf9fab8ae825f84b1681cbba1ee8e30e08ae18c75fbfc0ad7ed802b8eedd48b": "BAL Transaction", - "6c0a662c50f54aa9563d62c555fb5b2a2bee01d89855859902ba37b80db9785c": "BAL Invalidate", - "6c23802c4f205265c03d5bf743fee80f75883cf07a3eace7fb269225aaaad8b5": "BAL Transaction", - "6c4484fa8aea52829f76077be52e47de23d2dfc5cb4f6223efd6fe95005f8de6": "BAL Transaction", - "6deaab587aa728fc3cf41852a963920c4e4163856453e4d06d65417e6e5019dd": "BAL Transaction", - "6df509cbd56db433be0c27fc9032fe9d840336af58ca95c6c944e3f6c82bc785": "BAL Transaction", - "6dfa8bf0653257f85339a78b5f3201396e30e27d4e89ace66eade0f28a34b5b6": "BAL Transaction", - "6e2aea8713d51d5ad8712b96e7a22aac91be79a44d7fbe8732efb81c315efd21": "BAL Transaction", - "6e5e99bef234d6b3c91e9123e2641a606df8dea249c616f42c7e47116d292787": "BAL Transaction", - "6e8b3178ff725013483725bd95df08f7a0284f6dc0a62ab8740d191a8c5889b2": "BAL Inheritance transaction", - "6f249d1627dc0335467a63d408855bfee797a6e8113d97387b50f605e9efd8a6": "BAL Transaction", - "700d5ba39f4a2e8003222e54c098afffb5a264986d80b4b9d5cb7f2e3e1ab495": "BAL Transaction", - "70a46a5453c356de77fd205f582f6c3ca6203f5639ab7fd7d07bbf689b34df1f": "BAL Transaction", - "731a830914e176f96168137a0b4f147042cfd21dd63420df5a97d9d0d88ebed7": "BAL Transaction", - "73bfcc35e6259dcc5b25e940b40f45924fe64eb2f0a470df1f800bc5edfabf5b": "BAL Transaction", - "74c94f2d7734c73bc5dd084856ed7f61a05b7e5734231aaafb2907b45a0e9362": "BAL Transaction", - "7631d91254d0b219b549757d6f863c578a9ce70077c86d5dbcd0f4c437936e29": "BAL Transaction", - "771e9a33a584bbcead11ca0565f537ac7021042d1c927972bda3caa3e2e0339e": "BAL Transaction", - "78129e67d22ddefba4fb834e4c94e0967b8670c020bad30cb752d973538fea65": "BAL Transaction", - "782aea0789b089b516aba25034fb74c88a903f8044bb5149ec3096a13710f31a": "BAL Transaction", - "784e8b466bc58658640430cfd6c412c71056753dcdfe04d72ed628f9ad192715": "BAL Transaction", - "7867dfe4a6dd3d59b4781c09b77954f21365a73e368285227f1bcf8c9a04af38": "BAL Invalidate", - "787ea68c42b4cbec73a7b4fecdb9dcca60f37fb54ed8200e0e13d3f37f5dbfeb": "BAL Transaction", - "791422370f052af91f46e8bd3a3538d63ffe0f966b47b2a8da53735e8a3db8ec": "BAL Transaction", - "7a0ea84937d0d0dd1fbb79ad038eb5dd22096d466cfc0c428992b5ad0ce3e585": "BAL Transaction", - "7a6d1cc6be8f7f28728d9a518ce6c016cbd560842882efa05dea0a435e84f645": "BAL Invalidate", - "7a94407ccb2efc2445542bfe1107eadafb8da00692fb058c8ab3f8013ba7a013": "BAL Inheritance transaction", - "7a9c0596520c9b7cfa9b1cc0367b7f531a232407a1b859ef1aa9dc4a83f54b6f": "BAL Transaction", - "7ade990666b139f3d25cb67f7f8f931852b9ceb894b75fcf5e614cfee5cd7ce2": "BAL Transaction", - "7af66fc2d58260672327eeedc1c1e0abcde9907ebea08908bac4de385ae3e0e5": "BAL Transaction", - "7b65ff27aface04b738b0b32bd4af9b4bae16281ee9b104398cd02bc989d1776": "BAL Transaction", - "7be33b8465d0cc539990f55186d0a62c9a303b0f955dc2fc35a98da75749bb3d": "BAL Inheritance transaction", - "7c88540214820e2d728c7c5cf871baefd9a17cc8d2cb223473b1089b1da8e3db": "BAL Transaction", - "7d25d1b0e753475729b42391e9e4f8c6afdd5055d54ea18c92b4d7db5f6dda78": "BAL Transaction", - "7d5aaab55aa767a943c343226819a9ed404a0de5b4c795b525b347b51c5301bb": "BAL Transaction", - "7e6926289bea6a005a09b7a11e8d7b7a74397874de74920741501ddcd9189da5": "BAL Transaction", - "7e78ec1cb9c9ae8a948b2a3329edd9b48986752a6a9e86dc080e764d21945e4f": "BAL Invalidate transaction", - "7f70ff6a1ad00685939cd5db836bd6822314d7caaa3cb03431bb2251de578388": "BAL Transaction", - "7fc4caf7458f515d5243b351ae70ee75ee6b9441b9cd15a0b78c89288f4370b5": "BAL Inheritance transaction", - "8041b7ba7543b2d7dd21198210a5efc2c03ecfc4378d0b8a20aa3de0799b12ca": "BAL Transaction", - "808dc784760bf9e9e52b390a54a2a07720659b51b10df6b98dce83368c39fe08": "BAL Transaction", - "83acd17ba495cf3881d01fcc8d0d8da468147e83f6837e634278264b79c9a47a": "BAL Invalidate transaction", - "83d8f3932b429d8b251088ac783b1746739cf03443a2fc6d0feb410504523b78": "BAL Transaction", - "84b1458e2b62533f3245b272c4bbbbb63fafb0a619b748b5491ffae887af0f20": "BAL Transaction", - "84fdd35516ba7a970ca92186678158406f1bb72410fee6d2e525f5d0cb689430": "BAL Transaction", - "85a5799f2f9554e9eca095c2c5a26d302d325bfb3e4cfb19db3fa341d636d318": "BAL Transaction", - "86a65e1bad7b12d4343dc4c5abeb7d406f48c328248b1c03dcb550a8bb44d266": "BAL Transaction", - "86d92264577a793776a47e442977067995b3d00376d316a6c228b98031072f06": "BAL Transaction", - "86eb7a6a74afe568916ee532bf60faac8d828b5e2add8d6381c32319ea96defb": "BAL Invalidate", - "883b0c1e3f69aad6274e2f4682edd39f5f14de4e063eb04854af7b3f13f94fc6": "BAL Transaction", - "88481cbf04889aa56784130db1a4362a2e0a918b31fce9f64ffc83b6b5ec1f36": "BAL Transaction", - "88e317c6dce510fce347bbea35dd14b88c39d4387335f0058265b190112d0b53": "BAL Transaction", - "88e830b717dbf9411c8c7162fb4e3ad3fb30ea198fc9967a0b4ee1ba70cbe816": "BAL Transaction", - "89449e0881bfb3185eadea5c718c7f2761d530cc4aa70a59dd9e682b1f216359": "BAL Transaction", - "89a768826cbd9f1dc70560e9d9d10ab8186ab31f50866c458274e6ccfdeea83f": "BAL Transaction", - "8a0a1f2b01614d4746b4c812aa8b3d02542291b39d49470f49a6a386f2cab569": "BAL Transaction", - "8a7fe4c58d8f75f770a79602ecca98925ab3302e467653be70e6eb95d22f4a71": "BAL Transaction", - "8c665de13aa45f5ce94e9c58959fc1f4c5a2a447694eb3ad4dc50cc485e5aa12": "BAL Transaction", - "8dc8dbdc4ea4e05a1b418754cd4e7327df0109d845aa39863ba373a0a3718456": "BAL Transaction", - "8e91723a7137ab95600f5952fe461748193d7c7a2ce25dafb32c022ec755dfa8": "BAL Transaction", - "8ee8b980750d3cef2b2538841515b19e298aef48fbc0cc24d1debe5a0424318e": "BAL Transaction", - "8f88caf16e7f65691b44f5b74579e2c20bf67d339989bb2de5c8c4745cebeb16": "BAL Transaction", - "8fc580ecd8aa170f3546848f7dc632a40188273954787519f7c86fb2ae36394d": "BAL Transaction", - "9190f8488c7876d904574f24e287103186d3224892d3ae124d4907d444093c82": "BAL Transaction", - "92441f356b8deecd7c74727eed8bc922808531850c09639aaeb0013492d988e3": "BAL Transaction", - "92687e2749995130bbb0a7e2e89961d3aca94131099585d0c4d1508ebfa27752": "BAL Inheritance transaction", - "9277f76b387fcd8b8dfcfb953478713c81b0d19add332fd168a6cf880361f262": "BAL Transaction", - "934671a6ef20b76a3ea106e69ff79d62851c1213b3540bc6fb8c34a253b83dcc": "BAL Inheritance transaction", - "93c34606583496c377493021f40a44d6cc0f10d90d35e5310be562c5849dea0f": "BAL Transaction", - "94ab63ab27d9bbc9fc819d725a5b05bc03ff648679b448e92ebe51fdf6810379": "BAL Transaction", - "959f3e6f135b585811e87165352bb97ae696309a0e9e4f6c93f63c85b5178d2b": "BAL Inheritance transaction", - "95de189fc288fae9ed6776e73c092b7e3fc06a5dc16ffe46701b8c2955bbe04d": "BAL Transaction", - "96645e438d436b414910635eac3d682f7d5ba2dc44de58ad3ce888d4aa235a40": "BAL Transaction", - "96aca9e516fa3fd42fbfb7cc55d662ace4b4ad7ccab574c9c010196bada450ae": "BAL Transaction", - "982709b1000fd5d5e595cea9e476bf6c7ef51325b8c0788d81515f1dbc017763": "BAL Transaction", - "98b959e4921df6feab764eb7f41f5afe49e7cae5950a10decb6e3c1644d873f1": "BAL Inheritance transaction", - "9925e61bbbcc7b87bdf14fda598dcdbaaf8f77e0150c9db746933428ea7a83a2": "BAL Transaction", - "99fa37baed19ad25107ee1b374f355eb3abb5daf80125035fa32a22ab8b38ecc": "BAL Transaction", - "9bb9310610f731452ddf3a21de32156f898e5d03dacb25f29bfb8cd1f1c6f593": "BAL Transaction", - "9c081bdddc49f3f9744315e160d1e32254009befba10588dcfef801ba2fe7486": "BAL Transaction", - "9c79a40df32115b569ddfc2e00ae72d58a8a185bf9aba577cce4b6122db3acb6": "BAL Transaction", - "9cecf174c563798547d9358bc7b5eb0cd747695b0b2a6f8666faa4eb708d8d91": "BAL Transaction", - "9e0e87984a297cd34a51e410a1448df04c39deee61dedbf8df0ee1330a76b36a": "BAL Transaction", - "9f788f562c69f42568c81104c87ae2ab48eeb80080426c29d7a1fb3bbfad721d": "BAL Invalidate", - "9fb1deb7ed2372b376cb4a2db5293fe019bb2fcd5fc3ec1937ddceb8863d29e3": "BAL Transaction", - "9fcf67d652ed59a218ab111c78139a0e4fee3c2c5953652e9ea38b5ea06bcbca": "BAL Transaction", - "a0211dead55e542761c824bc571ee9675d3afbc35dab435d666b9060652c0e28": "BAL Transaction", - "a094efe6809aa7e9d1fb91b7c1e9480d61f8800979b5db8e8963a0a5520736dc": "BAL Transaction", - "a0aae8de03c7e03bb4e00354c10963a803d96f02bf94f3b615d1c1a6c93d6c4f": "BAL Transaction", - "a137345b785550922acef9a838063dbc900ac7005aefec02d6c0e579074b4741": "BAL Transaction", - "a1c09eac86d2194cbc568294373c9b2ca9b5c5a939c50fe202b0f7cb617d567a": "BAL Transaction", - "a2732199ff19d18cbc328e1bcdc7d4f1e0cf503b43789ce5bccbb06bb7f43ad0": "BAL Transaction", - "a2eb6bf05d350b2a243b26effccf3d55fe073e39c043fe8245555f922855a567": "BAL Transaction", - "a33b83d5484b65f1486bbda5e01b886ae149bcfe71a30c5e55541e086b880a0b": "BAL Transaction", - "a37c2e708ae71561abd7923b973456f09f52ddb39676c4d614205ba53deb1338": "BAL Transaction", - "a38fdd27e8ab86430603726fb3d9c7c30e452df0aca433daf822bcecb99c9fbd": "BAL Inheritance transaction", - "a3c1e6c863c3c4f3b8b293872fcf9092026993181ffdacf38c1a2582ba389460": "BAL Invalidate", - "a4881681364692001e38ffdfd5625aa5403a5ae5f4e460551de71321af562c0e": "BAL Transaction", - "a58ed3884582beaaa89a76897abdc210181d15731e561c77ef8fcce1003325f1": "BAL Transaction", - "a6fbad59db6ebd78efad6f100beb96fe52aa477319127405986a6bdafed02736": "BAL Transaction", - "a70a2d07244feb2350e9c57d4d16199485629249d1ea99899f14c175b1765c22": "BAL Transaction", - "a7370d4012ed5fe90e2adb65f6803a83f9880d6f11a82bb482a762e739c76701": "BAL Transaction", - "a82b44e37ddbd69a4109bb00e83667f79610f1a0901e50e724445e22bb475234": "BAL Transaction", - "a89c37287ae76914bf2d0b421ab4b3e444ed36f4c7ebde676d9013bba675bf97": "BAL Transaction", - "a91ff4a30363a1907d57093a92adeaed86820cb36e6225b571b17b655e0bf512": "BAL Inheritance transaction", - "aafcddf0b1f7c9b3d6d37a17ec7bb0871a0a0c4f2c321072ca9647c9d741d897": "BAL Transaction", - "ac0de48b1054ba94514436f54909001fb2626cf41eb970dfbad612e7a1d34bbd": "BAL Transaction", - "acd0108d51fd108143b213ada10f587eb92a96c7d6559dce8693a02bfe5e0ca3": "BAL Transaction", - "ad24cab12fb8afcb1cfc91710bb25ef7981302d0fa633c912f90d7d8c5e88c60": "BAL Transaction", - "ad2e25ec4473646947853704cb31fd2c37eefc97c9e20b28ec94b93a8f358c45": "BAL Transaction", - "adb13f7887359732c227e0d0523090de7d6cef7e7c73fb3b0a8b67844efbcd91": "BAL Inheritance transaction", - "ae3e03f0a49cf2f700804d44869ec51779f636b0303f7032781b0386fdea3e45": "BAL Transaction", - "aee97f5286815061bae493b883f503c43f9682a207d366ffba7400c08a961abc": "BAL Invalidate transaction", - "af8a5e53fa3b8976578119ebf79cb77aba0f5326453d16bab7b072fb7ef53cb8": "BAL Transaction", - "afcd4029dcb2444ae6925c1b39335e9ed2c03b1db4ef252baa96e51093000d8f": "BAL Invalidate", - "aff00deea35e2e70fc1519a67486545fd630ad74b902b3dca1a0b6b73814fa99": "BAL Transaction", - "b0b3f67b49f3684f14bbade6c4597e5b21efcbc2dfd1a7bb3f9f4cf568fa4d14": "BAL Transaction", - "b1457e17e01e12e67fe4002fa11c8d0465b1ff8b72e0bddcfbed42200e21be43": "BAL Transaction", - "b243d50605c9badec1b6febc249ba84de982470d01072baba6784801fa8f133e": "BAL Transaction", - "b28b20fb9089f2d021d812dc34ce461fdcd800137434edaa09160a395c182c8c": "BAL Transaction", - "b31bc4677070ba648435b8110e411163bc0d7399e97b3a25b00849b2f21e74c7": "BAL Transaction", - "b339a1dee6b3f9abcd718596a798b143f6c75d207ba25a33948d72d044d3cf74": "BAL Transaction", - "b3fa553c855e054e4dc4026685f7bba50ead5d437cb93b5981c262162c58791d": "BAL Transaction", - "b458bd9d8514ec04eb114db8de5e8ac9c320cff68b4d60bd9cf4cd39f2522438": "BAL Transaction", - "b485ce20a092e4ed6ca0044900b1fb1aa4466a8fa49a9513c1be341c056556e1": "BAL Transaction", - "b4b91b7c2b22fd3872d5cc97155f5b87bfb27ed8b9619431cf744a86d447c5d1": "BAL Transaction", - "b5097ba8cbec15c42291d216ffa9b56361b29f9b00465172c886f0cee82c87cf": "BAL Transaction", - "b57dc9027159df443ec1ea4307f956b3bc1be4c5137a936dd2127f0c163910bb": "BAL Transaction", - "b5fd9ae0d46b7cb4e0be995cec26cb0ac7ac6f1f22eed5153602c750916aa32a": "BAL Transaction", - "b6a0b12e7dad15f8166c9c3b2891a01b0aaef7a5143457064b838d88b9f6393b": "BAL Transaction", - "b6b5d5159c9d1440a15093672d65bd21436345e928b0bd36f2b08862d68d6c9a": "BAL Transaction", - "b6e993fb2b0584bb35b8eefcfbe6ba7c9b9b7875e69389063c96f78dd1fca9e1": "BAL Transaction", - "b6fd24d674338578282a3eb7322c55c8f54c4af4f3eeb97a4ee45ed6a2bc70f9": "BAL Transaction", - "b708916e8f8ab4562bb415f6876276e3901231649a9ed7f2d22ed0bae65cf0b9": "BAL Inheritance transaction", - "b84824bc26a8d8a3161d86c68adace287337f4478ac4b3ab6ebb1934ffeeae15": "BAL Transaction", - "b9b61961a556f0f08c646d570df0c790cae1a1b8fa7b55befcfb41d52db289bf": "BAL Transaction", - "bb08265c8e0ae8a07d304b928b867898c670a249265b51dcc5c768aa21c2319f": "BAL Transaction", - "bbe309728a8d1a95921b2598d6c487668c2bf4e10f2b1015377e5e3b17d811f1": "BAL Transaction", - "bbf4446b9b44512c5894392f5c77aad72443e63723f74a330aacf35bff6670b3": "BAL Transaction", - "bc3fbfee222e4a40495144481865820585226c89dea9fb16200b8e558f6682a5": "BAL Transaction", - "bcbe40a20f57437fc02129f60feb7fced71873c5ad711b0ef6ae5dc91ef958fe": "BAL Transaction", - "bdaa76d37810e4d4ad1d2e7ac6e198ad96c48683e552e874901a7af3d8ae4e50": "BAL Transaction", - "be0f8b9079ddeb14c9b12e1611936a0a08d03b1848875d0b7bef8d7a0abf8a92": "BAL Transaction", - "c07258fef47c48aae9e1f26073b43bdc0ea5c106408d05139859460abd38d65e": "BAL Transaction", - "c115377dc31ac1e047823b0b6f0ec5431e1e59ae60ee8df5facbded85318b6bc": "BAL Transaction", - "c16545119a8da2ba1467008f08f4bfedab5855efb35055e529f6fd14f3d526c1": "BAL Transaction", - "c1996651303f12c4de252c8ef3c3d75eecfbaa7fd5a29abbc76760103b8c9dfb": "BAL Transaction", - "c1a02c852539458d1afece20c5392ba1084d7dc7ebf3c0d5c279170b2c8a15e3": "BAL Transaction", - "c31945c85e814c80c2d1b7feb7a5211b256e637e350568f89c241c4535d3e37d": "BAL Transaction", - "c375a00e0ab9e41d86d0b2e2c794da72160c7437b79e0f4a51ff70814e760c01": "BAL Transaction", - "c377c527635d5623e2c99b7f55aac31232e74555c7587eb472979d4c99248c78": "BAL Transaction", - "c39eec964d6db4d24ab1764532e337ab47dacaf4fa0923f03efb451ae9348b7f": "BAL Transaction", - "c3b5acc12e932273f4b6e55187b9e07b138d37b2e4a298f33437f14cbabc3581": "BAL Transaction", - "c56d83ddf7a584f2b7b5b74ec893c237708d4f5bc1fa120f4b223eaf835d6f71": "BAL Transaction", - "c5dc0455f80b43c59e05a34c9e89bd577946693170321764d1f3ea590f2e74da": "BAL Transaction", - "c783dc05591a9634608577f8857969c66543ca5add4e32a502f9cec0d57ef5ff": "BAL Transaction", - "c7b50e7d83f268ed86a027679efac7dd9320792cd7db6305416929661ea81818": "BAL Transaction", - "c9158433ae3ad8ffd43ae29581cf304abeea34c682d61dc135510a5fc7c67367": "BAL Transaction", - "c92e5f22dde97f5c7e807a505a3d5ff2426f0f57607b7a4e27c8fd5d5d97603f": "BAL Transaction", - "ca2dbe11e9e775c3b5727b59ea134f2d0c0fffe037f775fa3e302f301b923c30": "BAL Transaction", - "ca8dafb74a0db880ac900eaab0048f67ae90cd3c6078febb8074423a57583cc3": "BAL Transaction", - "cc249f4b68e8d52c4a2622d86bf13ebc256525e2e85dd7dd02514658e869d4dd": "BAL Inheritance transaction", - "cd0d987cf28cd30df34adb651541e524ac61593c44b277647f0b41187a8abd84": "BAL Transaction", - "cd27e2c77a9742e8e7e6bfc535f83e1e7e32ec17aff8fb88ad3afa8452c023e9": "BAL Transaction", - "ce8453af49485fcb423dca1a56c24e1c2882fc48d20f7d7cdf50db94c7aabbab": "BAL Transaction", - "cfd133e808b71ec7fa62bd1abea2bee8b8b4bc62c4fdf4191496b9a13b3a3581": "BAL Transaction", - "d121fd1847e9157da0821961f42afdc9398053d32c73bba602b765c104c25e6d": "BAL Transaction", - "d1ad9ae9b9157e1e226b9e838be661863e2f8e676b226262f19b796d1b1af680": "BAL Transaction", - "d2aa2659eff3afaa03df3857b9c651ab446d3f30cc3195779f5cbbd7f4a5b21f": "BAL Transaction", - "d371cd377314c8b9368b8870304dd329d58dd9ef3b4635483907877f10c2855d": "BAL Transaction", - "d3c6048e8b2d169ea88a8b85869e996b165181a8532853c1e94eb6010a53164f": "BAL Inheritance transaction", - "d41a30d63836d8a41bed7a819a61c7383e728fbac49e918ad099b96dca0af634": "BAL Transaction", - "d4495edb0266dccdc77a8f1f6899f0921f56ba14e80163578d122dfd9f0251c4": "BAL Transaction", - "d46d913da6bc925eee8bd5f9d657ec233d27710e77dcdb80252f52be11f63cc3": "BAL Transaction", - "d6093367d0f418455a6a97bf195b7af3d1a798386c67ae0d95d91bed3df21994": "BAL Invalidate", - "d70076673eacd58fb751208879026017cf0e5ece79aa61a51d97fad41dd6a66d": "BAL Transaction", - "d7fe2bca48350d74a0b0a1404f4eb1f8ff065188c243e0a8887da5c5a9758ebe": "BAL Transaction", - "da413024e4e43713a57330935582797d54ef1941dafb31198d3781e109cbf6cb": "BAL Transaction", - "dabe77c276942c463e0c34b49b1aba08f563dce97a32c41c098d59e58f476539": "BAL Inheritance transaction", - "dad8661abf23b3e70f9a1cde4b5c31ceb4b4fc20b993cd181f3437336e3deebd": "BAL Transaction", - "db0a6167c75774f2f5b32b7387a2f28fec74ff13646ede4fc586a4d602c36c00": "BAL Transaction", - "db4bb399b37e567553605a50afc1c2eb570fb621994bb3f9ef8f7570ef71f174": "BAL Transaction", - "db906dc0195f5efc97e7c27320684511f80c0ccf41dce2857acf59819e45be4d": "BAL Transaction", - "dc0a1c358bceb965fe57a257d4b97693474f7251f95c2d90ecca8df402edd1a2": "BAL Transaction", - "dc7fd5cce628f979ace8fea7bf00d9e02747a2f220b9cf1d5ec2006912ee59e6": "BAL Transaction", - "dd1536302b7321252eb68fa30bdbeb13989934c1bc946f005dafbe8bc8c2b517": "BAL Transaction", - "de35722e384e4903dd5551357d9276e3fa1f7c9b1c97ad943e4bd91fa9d52ed9": "BAL Transaction", - "decb3c083ce18a76b8371a4533b90a094ce8cbb0e2cc64d17071a51076ed1cc6": "BAL Transaction", - "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8": "BAL Inheritance transaction", - "df032c7e361179bccd7b93a72d98d81872d29d704c2444acc3db7a4485f81e02": "BAL Transaction", - "df31987dd7c3dc785606d3149d58dbed33317b959fbcd5e3037bd2375cf93953": "BAL Transaction", - "df4e9dc8f6442c08ef66b16e5c38da3f611e27b4171c5fad42d7a21cb458eb0b": "BAL Transaction", - "df91e5164f39675846a1319b0d9486d2e0a0bd683ed07e43e539caad318f1f48": "BAL Transaction", - "e022cd5398ce3e82545681e12685dd3425f9d476b00f3bf1fff4bff5c0453c8d": "BAL Transaction", - "e06aab56cc626d11a0cd7b116e51cbb414e04212cb61082d6c67bbc74b8bc144": "BAL Transaction", - "e160dd89be90ec695de63786285ecc5958aba9c225e50f345704113c6732752e": "BAL Transaction", - "e1742e6d9a9c3a60bab39a0796235511f3539e8eca13ed81349ccaf95d21f516": "BAL Transaction", - "e216f36276646b01f5e5af7bced7765a18870e8b5a7b0827938cdac8b5d97144": "BAL Transaction", - "e289ca8134bc76e77dfe30295482489bfe15831bf6d6326471fb8d1fc876ca96": "BAL Inheritance transaction", - "e2c550c4613e5126df9c69383535f03651000902378979658c5df090cf6f811d": "BAL Transaction", - "e2d2f23a7291b8cdbbffffc516e433a30c537f3c4f042f9a5aca924d57cc0ea3": "BAL Transaction", - "e41564ef450198b9d55c845e1ba85a5385547d510423fe32fe475b7ef279f60a": "BAL Transaction", - "e426bbc75d31306f624a8b4e4271e12b382f4b038fef3bfa928d02b4adecb463": "BAL Transaction", - "e4f7fde788d241c50bde8cfd2e62008fe1f21552331368ea92c2c3a7e2a258b9": "BAL Transaction", - "e5013bd70e832ae719bb98f8ceed8238b87c1b8edf7d3bdfe3558eb3adcf4259": "BAL Transaction", - "e50b96a6b11b5c76c53dc3e56aee8dfb752cb1840945c2c3c891268028cbdf5f": "BAL Transaction", - "e707fbaf39dddc558c57c9b6956773a41ba850da308956d207d2f69ff5c95a14": "BAL Transaction", - "e75c18601967212edfc1fae40cdf2b124b0a271b8d2b06adb25570178ad18427": "BAL Transaction", - "e785681355c81ac94103eb556c91ee5f09b8be162ea370b33c891b3eb2c9a0f4": "BAL Transaction", - "e7a29ed671aa761e651d4fed7487770244def466eb0d04ae214580d587691fab": "BAL Transaction", - "e7c936c18d8d3c124db6565fbde75826ce6ce430e47d99427476741cdd116788": "BAL Transaction", - "e834b850d8237298e15a698865966becce29fd5c4b708e0af8d99641540e3283": "BAL Transaction", - "e8bf4484e092453ba52aeaae80c36dd70940a6a894a02d164b2dda59edd9bdb2": "BAL Transaction", - "e8e5a5a471de09190e2025507d5b35dd4be670a175a7c56e1b614ddd6cb6f9d9": "BAL Invalidate", - "eb1417a958183da33a6397a482bd346f0e8d5caa35e5331fb158c6908d838782": "BAL Transaction", - "eb51046b98d7240fe428c3af0d953c0a0a1a0d66ba93cfa715676cca77b662df": "BAL Transaction", - "eb9e58480b7d7bec43a8a5683a61b7fdf7a5d201c73f2996b7fd43d3701d67c5": "BAL Transaction", - "eb9f798774b196368f5f1e24a22669c48dcd8406aee7b8de878ad0a4b42a189f": "BAL Inheritance transaction", - "ebdafa5f7369da5f0a1f028dbe691341d21ed2be2841d542b7cdc912828779c6": "BAL Transaction", - "ebeaa58132e094d36c1547b3869a09fca67380f0d91af6a258db2f34595be8bd": "BAL Transaction", - "ec93c51e45cfb522369bfab0b576d162980e0f4a3bf3ea67a1e163990bf64073": "BAL Transaction", - "ecc52f18f8c901324d1754af8d9ad5087976518c8c14f904192801925568b914": "BAL Transaction", - "eced91d575c32dbf7b9712d7f2e6881f5992cdad954d486f4278f223cc7cae02": "BAL Inheritance transaction", - "ed2a6efc33fe112a71f99064a639949b83b33f04b64e3899a694c519d9d87a78": "BAL Transaction", - "ed84cb24feaee9ce5e192ee8074be273be4e26752bb63eb73daeb0b640324bdf": "BAL Transaction", - "ed8d44d7e759281f28590abc46d011bb071718800d9ebc280e6e2d1726701cd3": "BAL Transaction", - "edb2daf688ce7a74c9767592252b99b5822412e4d6228b4fb6b89f55bab74515": "BAL Transaction", - "edf4cf16e67adb2b4edae518122f902d5e6c07499ea4f5661e32b272bb8d67d0": "BAL Inheritance transaction", - "ee359445be96eca2f001a4b3643ffd091e16d9fbb1890f89c7f922b61c9d231a": "BAL Transaction", - "ee5d4e94794d6b04438601635be8a80816625a10f0b119fa144f2f295bc526e3": "BAL Transaction", - "eef9d5b762d135cd3d58d677fb5aea2b4add475df034a7783ad4b968eadef6d2": "BAL Transaction", - "ef08c7d03602d8d242a7a7ac5390f56160884dacf939fe408eccd756cb2776a7": "BAL Transaction", - "ef530069ea353acb0e0c39783179d7987fbc4c886fefa52ad6029deeec10d2f0": "BAL Invalidate", - "f06f77c206d88d4329518ccb83e42ae1b22bc5a763827f404fe3b54b61f01989": "BAL Transaction", - "f1516704e850e5b1ed4c9a6320a48a8ae81e2f3f9652a4accedce7ed0837e201": "BAL Transaction", - "f1623847ce6864d02e2a170a9166dbc3cd1725671b1afcf6e729dbf41ae48c69": "BAL Transaction", - "f1c2d97b87ec7ed1b58e8b2b70dd3263e958e6b02a45477ef9b2c5f98f53df25": "BAL Transaction", - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": "BAL Invalidate transaction", - "f3fe529f7269138b4d08bb934e0d61546d5d590db7a3f62a23a360590a3d533b": "BAL Inheritance transaction", - "f3ffba12772fa75e55a64ea23e82cf3b9e9a741e6df6cfcb913194bdc90b6c94": "BAL Transaction", - "f547b63419bbd93363c074fa69624f614e170d43468026d993a3bf5fb0898f99": "BAL Invalidate", - "f5b006855cf327b641873c53ddd019dff16d55db335178cbd54125adab9eba5a": "BAL Transaction", - "f5d8e8ae63219e14035ad59af429e53edd6a1495887258284c2588e18ef65d22": "BAL Transaction", - "f61d88db22b054474f912d6e3502a7403e22d09d3a3b314d2ab9a564fd14d407": "BAL Transaction", - "f6bc8a17007ee281585e1dba07ece0df8fe3d2e548b53db88e320974840cfd45": "BAL Transaction", - "f719645364c1bb9c7ebd476503728fd4c9f9b209ba5696f0cc066d3f71508bd4": "BAL Transaction", - "f71c5dee2f011ec760c41815009965606963df9c20f3bb1ff7764115c8095a69": "BAL Transaction", - "f720e76d8c06a16501a92e80013052c86a1f7bc8cae808cace19d7f417db8900": "BAL Transaction", - "f785378ba4d44412771647677db6eac88451a0d2de293163936f86339eaec26e": "BAL Transaction", - "f829c9817e3c74bc0af1b40bc62edc7a99ca4f35357bdf3d9e599299767557e4": "BAL Transaction", - "f8765b384280f20fa4dcc47c8288147eb0ddf835df716a3bb80e2d283657660e": "BAL Transaction", - "f9a6ffb142e85db26b74100aeb89c6717e3c3dced973fa8b63655f16b6d4c334": "BAL Transaction", - "f9adcf1121a1a6919bddbf75c0621ee6cbc7e05ee7f2c61e7742f62c9c1e4ee6": "BAL Transaction", - "fa3d6cf54104c63520423392c8237a8a685f1abd35b87c2ce018fffc484e8a5f": "BAL Transaction", - "fadcce9e2d21011926e7a0740674610a395b1e181f9a058ea5944af7f0dcefd1": "BAL Transaction", - "fb6b2cb82e9bdf22c6fabb9690d649fbf22b5f822a32e662b5132230d9c9604d": "BAL Transaction", - "fb7721746c4da7b6a554933dc611f974825f609df3b1f6b40451e18d2694b168": "BAL Transaction", - "fbdbb91e7c21eb43d4ab1fa91c541ebee331e9deb1261b64d4f541ed4423bcbe": "BAL Invalidate transaction", - "fbe7beeec50d660c6d3b3579819caf3583c742960e1a13ab4bbd26d7824d304b": "BAL Transaction", - "fc6884650d058261db76dda29de657120f0afe2d5a0b836ac4e5cdabfbf4ae4e": "BAL Transaction", - "fc6fc5a2fe6f0873c625e13daf9a8692c9fc3734cc6b5c63d7ff4e756106baec": "BAL Transaction", - "fc85db253d1ea610d309fc36ccbccb4a976a133ffe63c6f89f743b3f90f84c29": "BAL Transaction", - "fd00b6438d67881348ad532267d4df3593faa8cdad2c3db918cfde26a0ecf349": "BAL Transaction", - "fd373196d9dbbe2ecdd05449fcc073313f5f7445e2464760ee312e71debdc9d8": "BAL Transaction", - "fd73fff35cbac546afe7e78d698bd171a791701086f8f3128efb313cdaa56da4": "BAL Transaction", - "fd8f6e9abcf8d1ae731ac1e7018d060d373b33fff718f4cd9d23e34f982f9413": "BAL Transaction", - "fdf51361e239664d6d58dfdec5890b033e6510a9c2fef0ffdb085e9cf60533ec": "BAL Transaction", - "fe43b5d35888009f3de1cf65f2de7d998e9c31299512495bebbfba99520ae3c1": "BAL Transaction", - "fed0a2cdea7ff950695339d325e17805896071520fd6c493d9e5033f3ce7c2ef": "BAL Transaction", - "ff77d48e160aaec0ee784bbf22aebb85be00881b465483121ee0ba006e3118aa": "BAL Transaction" - }, - "lightning_payments": {}, - "lightning_preimages": {}, - "lightning_xprv": "vprv9HMaVA1cGK7XCUCiTrrdD9kGgiSeiwpqDDYfAafTZoSXyZtmQYnQ4CUvsZggS4fWrF3kve47MFWjrWLJ6t4uXjzs2XagtmBeUqoaRRFpJGF", - "notes_text": "", - "num_parents": { - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": 2 - }, - "onchain_channel_backups": {}, - "payment_requests": { - "15e8cea34d": { - "amount_msat": null, - "bip70": null, - "exp": 86400, - "height": 203, - "message": "", - "outputs": [ - [ - 0, - "bcrt1qpm5utekdtmzwnlkh7jq5497vwwf6sm38tljan5", - 0 - ] - ], - "payment_hash": null, - "time": 1787139935 - }, - "6e7ca1969b": { - "amount_msat": null, - "bip70": null, - "exp": 86400, - "height": 1371, - "message": "", - "outputs": [ - [ - 0, - "bcrt1qdnjd24626q4av9kcl373gjml2wlx7qrwhtndpp", - 0 - ] - ], - "payment_hash": null, - "time": 1775397781 - } - }, - "plugin_data": {}, - "prevouts_by_scripthash": { - "00797f9c486baa77c27e03ff9282004da640ecb01f39bf7b3c598cabf8bac843": { - "710d8af6e637597156655c8f4f77619396361a23c449a45c11e5073bbb37155b:0": 207235760263 - }, - "018f5a047cba6fd21383d8d9150a55fbf386bbe2b8d7760451ad4f437ea0745a": { - "58b5515926cf07136d98f2b0261134eb76586fee495f9848387458b14d74c9bc:0": 1250000000 - }, - "01b7197892dd56b68db78eddc2bdfca91bd7fd12ecfdf26b99bd1489174d7489": { - "8081a8fcecbe22cfda263a28e331c5eadd9f2924f554afade030a2c57ca18a1b:0": 1250000000 - }, - "02141d61293b233bc7a422a144de5b657f0404831f5abcd8a28e64bab3bca346": { - "3e90e0c6d764847423f69b9b18156ec213d7e997302ed984c50f82e2ce15d543:0": 1250000000 - }, - "04a37078318d158f0c82c111a5756328590da8cc069547217ad2494dfb70e93c": { - "2c7b1c375b27d7c61eebbb1d9feaeefe0133af1255ec705e4053a7ec66be732a:0": 625000000 - }, - "04c15fa71da36b0ef937c2aebfcff476260ef121088534dea681c1342c1e5b94": { - "405f8a3b27b271b5d03581234028e814fe2a040f5c963b67e92b3946d0968faf:0": 1250000000 - }, - "0572c53a4ea6a33047f86ef84f5b0336b62c8f5dfcf20c5004907dda2a67f676": { - "3ed10a61c4b0e92b1195949098c878aa313487cbfdfd4bb921263136cebabc8d:1": 151306 - }, - "05e130188911da080191c6967cec2f06b45da5abe8796e51b9b1fec272723e7b": { - "c02bb5cff013bf0749c345fbe1144fdd18e759efb3eb5ac66a68f2012c77f974:0": 625000000 - }, - "08ccff2a9ce7315cdad6c2710736372cce44d616d97b6fa435a2c692902a3ad1": { - "cfcc6767d5b0cb4cbfb1f7aeca7ccdbc1b317f175222ffc572eb491f1c619264:0": 1250000000 - }, - "09741d16b48cc6cd17a2899f2d144a1c70bc7850a72e3fb0c0f057140c413fea": { - "f54b7b6d83112b7f7e052e296110638d22e142fa55ec651afd0f4122918b21fc:0": 1059703 - }, - "0b4a9ba49681c4c11b984b22a4efa69e6b2c94cb1c329e3dbf1c9ac0dfac8bf2": { - "ef530069ea353acb0e0c39783179d7987fbc4c886fefa52ad6029deeec10d2f0:0": 206844912801 - }, - "0b7677c897968c4a4fb8d07ab4b29a609ca3997c8afc08be6674226707128f97": { - "467dd15b59be876ef199d91d606ba0ed1adec119ced785509457da0a53450842:0": 1250000000 - }, - "0bb6433172dee1ae0329f5c23cefafdd27ee6384d8dade592db88634ba2490c3": { - "02dfb7815efabe1183ce34908d4a6e6a3f25495ad5f57120640843d418479e7e:0": 50000, - "065cac0bb30c763b66c25e890be537589f24cb90219bf30da96a5d37e051e052:0": 50000, - "0fb9da0e14c71d8b85bf00d23fa3b599972ea48f94e567e0f9733ed7357ba686:0": 50000, - "1e4d59e4cc5b7cd10be6624dd46bcf90cce2fd6320fbd496a71117cf82233df4:0": 50000, - "335d73d9f9295b283a07187652a587830eb1eb2ee2829ef566dde8445946e474:0": 50000, - "4b04207325babc3e30db43a780637e65263082ac655519784c9671209953f7d7:0": 50000, - "5a5a803fa15d65a9cbfa7f72622af5d43e1027cd15ce33ded0fb7dd84d5aa0fa:0": 50000, - "5c6d7a19cfb35a98949a25c5fede1dd798a7e1509e0067d000319126c34f8c15:0": 50000, - "697895649863c50517092f62b47284ab00dd812a100a2ca6ac9469b5e4f6da2b:0": 50000, - "88a56990a69eb1a7c11595b7a88097d799fb6070bb2f77ea34e7f2fe8d54d867:0": 50000, - "8cebb4ea3a63829a2787c3a473f14b4f73ced07552f73ff8c2b1152169042550:0": 50000, - "a001edc1d43b5b41adc5a4c5ce9b6edd9dcad9fa3e50cca287d256a23cca9d4e:0": 50000, - "a1aee934c5dda700d15934667507f90db1ce36d7dd3068165b54159df320cd02:0": 50000, - "aa407bf21c31fa3ebe8bf074767bfec17df01644da94b3b709c3d3551f3210ca:0": 50000, - "b28b20fb9089f2d021d812dc34ce461fdcd800137434edaa09160a395c182c8c:0": 50000, - "b6e993fb2b0584bb35b8eefcfbe6ba7c9b9b7875e69389063c96f78dd1fca9e1:0": 50000, - "bdaa76d37810e4d4ad1d2e7ac6e198ad96c48683e552e874901a7af3d8ae4e50:0": 50000, - "c375a00e0ab9e41d86d0b2e2c794da72160c7437b79e0f4a51ff70814e760c01:0": 50000, - "d4495edb0266dccdc77a8f1f6899f0921f56ba14e80163578d122dfd9f0251c4:0": 50000, - "e216f36276646b01f5e5af7bced7765a18870e8b5a7b0827938cdac8b5d97144:0": 50000, - "e2d2f23a7291b8cdbbffffc516e433a30c537f3c4f042f9a5aca924d57cc0ea3:0": 50000, - "f1c2d97b87ec7ed1b58e8b2b70dd3263e958e6b02a45477ef9b2c5f98f53df25:0": 50000, - "fcbee4c653cb3695c31817b0fb8ca1287fbb67fa8ad72801b520eae222878536:0": 50000 - }, - "0c73708c9245d790e98921952e5512f54824b74c316335e1bac4c5148ff82e72": { - "d6c4c80037ef1102f569193b117c4e2844136656de105f8d16a4cd05556d1d94:0": 243323 - }, - "0cd33f6239fab1c84181a54ff73e394a0a4146ba7baa57a646d9c11ceac933f9": { - "b20a692e1d25335e608a57eca240e4a212d096d0233a98ee2ab8c6ba49b3f2e3:0": 78125000 - }, - "0cfeab1d9ea2f3354e7dcd02e662f83abd4d9ba140754ea6dd271f5db8ea0aaf": { - "6444792f99c990677950a6e8c27a18eb4aa1eb946763887cd8c67d7495cbd1dc:0": 2500000000 - }, - "0e666c3fd703d717797c4fd2ce71c508b9bb243a3b6807b73b808b08d417f25c": { - "00fda0d8fdc53b1f95410bafca884c5dff8ac1e5c168ace5f09a32d872b7413a:1": 0, - "0254d8a8a3e3a8389927332b075fb8bd190f6cb789f5bb3941dc1ce6581e476d:1": 0, - "02b85518c4b0d2c17d43e88cd62552b0ab8da2ce0605ea3a8b3cd70b0395c6b6:1": 0, - "030a1ef891f4a7179a5d050fb5160c361ffe7a5ba72211e4181de177224ea25c:1": 0, - "078905c318abd82e947af0d3836f38852f3463fffcba279ccb5d5dce58bfcfde:1": 0, - "0b2d3c47e2f34a34baf07ed83308aab05101b3afd3a0494167e6db0c46bb7bf8:1": 0, - "0b34c01afa7c05d19c442b8a976679a3ba3c5192e2ea155d83ada16630dba360:1": 0, - "0ef3b8b6a2c3d28126a7c79aa95b5bd1f091b7eb8c14c20fd5ee89c409462c94:1": 0, - "10c52f9e9340e2e6050fee8672c62ff10f4fc1828c877a263efcb03d91029441:1": 0, - "129a01db49fce205b86d4774ac012c1bdf4c5c402925a4739dd9797e424ae58b:1": 0, - "13c4b8b888b4c7f68076855c11d51eace7ee8d6c106142b493cf47a65a8f5be1:1": 0, - "13e88bc0b5e4d3bbc9ac545c9385133586923536a901b6858a6cb4abb76eb2d1:1": 0, - "14989ea4da2283527831cc304ff559b776a79a2a115c03ef976034f18b44c105:1": 0, - "176c67ae60460acbb2ccd663b0c6983eab6766095870ca6e6e2ffb77b310316e:1": 0, - "18b7f4716ac3a5d9acff42b8b0d3e95dcb17893f7a71dcaa135a3d085ef532e3:1": 0, - "190baa4d3d24145ae21e16b9786bb15452ade631265b79c9a30663b8dea66c75:1": 0, - "194156b5c46845aa63322f50a1320f41b9b0368f4310e65472a273e1a54c9ac0:1": 0, - "1b080dccf6a653d3177683243719ebe9eefd62fa4c65ecb040b9b315bef7d54c:1": 0, - "1b3dcbd7bf37a23b56ee9db1182dff5e4cb238bc112b52cc3059f037c44bcac8:1": 0, - "1c5c1433a13e1f7bd62b2f96c03839202ec3051e551724ae5c1022359b500903:1": 0, - "1c6852c5b99bbe89e5cde3db9074dc2a84a3f99e85c396a05ae704058d3f90ed:1": 0, - "1caa837b9b99f86cc5d0802d2da24bc4482138e10a280c92e85e8b80130dfdaf:1": 0, - "1d30124dd0acb5c11a2cb8e607c50b5f402a7be178cafb264747fb0dab37c8cd:1": 0, - "1d8f56e554f12225da4fdbf0fe0f9b73a9649d183d14a59b7b7b6f21941430d8:1": 0, - "211e0cb1f99e931a517437fe2b1ea81e97332b8abf066a99704da17bcbeabb43:1": 0, - "26171dce2366ca96fdcb0749bab05fa8b5abbf9b3a4dae1b9ae6f7f5425108b1:1": 0, - "267eeb8e65681ffaeafe0a04616b270750f1f0a83e5d51f82974ad0c98e2c5a6:1": 0, - "28837747eb6a849543f5f03063f1436fc44f64f028448022ec0c382ac5bb7c31:1": 0, - "2a35cd9588ee727bc532bcd185d8ef78d5d54ec168cdb2d49516e8042ddf05fb:1": 0, - "2ab0948cddb04221e6ab88d2d9ba5ac11db5e5d1e0ef89ea8cc69b19a17dc6c2:1": 0, - "2af047c4a003fb7b90ce2c8fc49bcdb3eaee0fff213b14dc1645b9fb18b8f0f6:1": 0, - "2c6d3a41eb0f5589e2586138b2bee1cf453144fe48dc73c2a3b0f4d0a3bfd596:1": 0, - "2c7b1c375b27d7c61eebbb1d9feaeefe0133af1255ec705e4053a7ec66be732a:1": 0, - "2c8737fddbcf0d130dcfb72dc0fa000a7dc70dad88dee6aed2acac1320e8fd06:1": 0, - "2f693a35d64bf0e9091c33316efcdd607c2a7fc06ee37b2b41588ab84a280e5c:1": 0, - "316b0ac8e6516b59633a46dd5f4fa19da180f799f871a57085f6a7e99bb561e7:1": 0, - "32656b80136626aeef1f136f9e9d2a2a480df0de7b69a195441ab611008abaa3:1": 0, - "34a279c4278bbf42736f5345d5882058abb0a4cd7ba8f9ec3c094e9dc9ec008b:1": 0, - "34c0aa189d54f801cfe9eaa8ee86d0b9a931e6ef67e5a00d68cd060ee16787bb:1": 0, - "36ac1d694a8810ff6e930a2f7751191e72414ea832e8fa66ad4d087f09064cd8:1": 0, - "37b933d6b0d52746412a4bc06d9e7bf6b15cd42cafe8938f6f686e416547322f:1": 0, - "37c7f8e63152bad6ff3b704e4a60985cb25d877606c7c0514e7ada480f99ef1c:1": 0, - "399231ca4ba596e68131aeb0b7c5e721c7b90a4e7c27e4848487fb0453c27d2e:1": 0, - "3d2f9eb3a839a98bb53c6df9b9df3af9420f487955329009ebe6d5ce35c0d7de:1": 0, - "3e3c31ad2bb0d69050a4880982e1884ca56be999b14b53d14e4c60524dc224de:1": 0, - "3e90e0c6d764847423f69b9b18156ec213d7e997302ed984c50f82e2ce15d543:1": 0, - "405f8a3b27b271b5d03581234028e814fe2a040f5c963b67e92b3946d0968faf:1": 0, - "407d783397625ee8511d287dc7196ae6bfb9ec7e86ee8f37d8ac35c27704737e:1": 0, - "411e92637bb0ec8c71ec0bc892cdc5b897d26dbd03ba437ffb57db58ff320894:1": 0, - "41df75627f82b7db83eff2f542ad814e0e013bf44d4f67e66414d3a6a0ecb3b4:1": 0, - "42a1d1b8b6fbfa6b9f1158c2663e00c023a278d9c587901a40ca77fb5835e471:1": 0, - "44461d09d9fdd865944d6557405134f19cfd817bc293270223c7a98c862d03a5:1": 0, - "467dd15b59be876ef199d91d606ba0ed1adec119ced785509457da0a53450842:1": 0, - "506e0e953ba217afd18465980c16438947d86ebd74959c689b3b53d1dc2fe2d5:1": 0, - "516145bf354893ca80ccfaecf05620a4d99698a515d135cfbfc4e12f80f617fa:1": 0, - "52803985df4cb02bd28ddbf089671e5ae130b366bc348fcb4db0002c3d2a0756:1": 0, - "538f17b402fb20163d67d4ea088875bc89bd4825dbfe18696047e5f793ab4f22:1": 0, - "561baf14fb75bf05ff0222d5294d5332f500a9c9c4f5948eb2903120ca606aaf:1": 0, - "588d3d3972eb0ef218e66d150888c10550320cc4fe1d2be41446a0fc9d7b9822:1": 0, - "58b5515926cf07136d98f2b0261134eb76586fee495f9848387458b14d74c9bc:1": 0, - "58d61d3fd5b95f32e3475474f6f60f4822196944d1707a725e7e3e68d3467ef2:1": 0, - "5913dcfb1843ffcc113fdea4df6be0d5fe6ec1546f54c7387ae85d22e1062bd5:1": 0, - "59302675cbb9ff422c5c6ac365a1a4f2798e9183c4f027463573f0cf1ff9fb26:1": 0, - "593bb86df1956612d130a5aff27372e6d4c2c5d56887a4ffa62a06a1d729df37:1": 0, - "59ae2371837bd090bed2ea86faa9ca77eccc9d74dbed701f9398434986a7110b:1": 0, - "5b46a61246cb95387b0ae0d1a9691091330cdec508390afb51578f9164545916:1": 0, - "5b67bf78a3b367a4b05806efa537dc1ea82ae8bec788954b5e178c21ed53b385:1": 0, - "5d9d23e6caf4d2bdeac42ccb33d1c867f5b356cada6f4a301dba292354909a62:1": 0, - "616f81a3dac9b300c1c95ef4353c30425b29f73cf6781b89263890da79278cd1:1": 0, - "61bda73234fc36ba88dbf8a5281e419f8a2de1d41c91c2c61c0f03bb48d15b34:1": 0, - "62581f3d27c05dc4573a7cd31afee05b6ccd2209953cd924a171cb1021cec290:1": 0, - "6444792f99c990677950a6e8c27a18eb4aa1eb946763887cd8c67d7495cbd1dc:1": 0, - "64f40121401bcfa201bf59dc9c8d23e2fe89da362685bf64694eb98f6f4fc991:1": 0, - "683425171b094d3e222a7eb6327e2276e8a66f02314d57cd3c50271eaef8d3f9:1": 0, - "68a99483af54769940b80095e83d95d607f9c32c2d03c0b89a86cd4143d65eb9:1": 0, - "68b9c5bddb83e4f54192b67a978f6bea1e305ec558c1042623d6bcc54188e135:1": 0, - "6a52b7fb5be6115a7d4283c8a3b4769a17de45e61eb4925e99e8af930c4c4ae5:1": 0, - "6ac5003d72322aec68d0e987acfa8c96d3259de8a2bbb93ec5c36ed84ec361a1:1": 0, - "6bbfaa6c096b36c2a91c995e370e4721ece1b07a4c5cfdcc6e728f074f317542:1": 0, - "6c382045994cc6a3ac5c1079d815ca306c95100a1ab3422ccf8464db49cb9ce4:1": 0, - "6d9fca805caa0fce2e1fd0348057e2b26061f2a3e440d3f16f37fa35e2d7e197:1": 0, - "6e2570c89ccf738ee7f67c25abc7feb2b85e1501fca49e05fbe4439ff8ab7af5:1": 0, - "6f16141099318c869291db432b631620b5f182744e8f50a279ff5cb62f063485:1": 0, - "6f61e5d05d994e581c48770b5a9800230fa3bd4aac0b3823cb9f3097b36e5297:1": 0, - "718b9758764cde8e5d0dc0824917e795cbd64bdac2687033e379e556933ce1b9:1": 0, - "727450a0d92fc99255fbfb5de85245b2387314f347f12762ca7e3aafda0ab5c4:1": 0, - "750b38d81dbbb4eda455c2fdd27bb746c9eadd390034fc184797aceed6b1d20f:1": 0, - "757ddbf9ff3cf065bb602a13356ffed5374586ae897baef6a2b72386e8b8aaa1:1": 0, - "7698e42487b56fac8ef417a78e02af91ec0fceb77f5f45a5e8060982bfe143ae:1": 0, - "77270fdd97f95bfaf6bd3132246ec2aba1ff9a0dfb92c87791aee18cad6b0011:1": 0, - "79e66107ee8ad8a0c93b34ca80bd83c33efdc1eb25dd1ff902853bf77ca39cf0:1": 0, - "7a0f7f676756ed94adb07d623865fa5e2b702de76103bd50ffe26a7d8366d707:1": 0, - "7a721c4246058cfa85feb2aa781e03ff7c6a0a54b796dbada167543c42080dce:1": 0, - "7b20a4d66eadd0778533055bdee6761d3855f17cde3fe4cff83b1482ce64ca02:1": 0, - "7b5ee04a3769bc5fa3230eb0fb1f7aff9b0ff9fa70815b77d0cc828bd8e51303:1": 0, - "7da003d3eade4adc86e8d65bd82288850b119908aabcd4e18a24d1ab31d88e14:1": 0, - "7db9f22f77a74ab5402fbd3039613df49271101f24c1cf6a7ef1748f2392dda9:1": 0, - "7e95e555689207da7fa5ac7fcacd36bdd6af6db0c3f975032a5684c6914eba39:1": 0, - "8015500aaf8017f883467cb6d8ab7d1ac0514993af4a99fd303ef5d4c6adda83:1": 0, - "803341da80f8d6939a6aa1541d1ffcd27445c823178fc45cf1295b9e3a167be4:1": 0, - "8081a8fcecbe22cfda263a28e331c5eadd9f2924f554afade030a2c57ca18a1b:1": 0, - "83294f6f855ca49163df69e018a87ac551a415c15029e82bc5d769c907e9a73b:1": 0, - "83d64baa165f011c9c6902880c2fefada3df75c1271a51da205e383cc6d58327:1": 0, - "856f4b1a6c2b8055e6b48686129878c68f83e0444a00430cb319fd7186e9c1c7:1": 0, - "86ebe3393b3bc36061d8584542d5b610ac62eab456effa5f3737dc2af00e4a0c:1": 0, - "870344155014b8e1f8b649235f228c8b4833aef7265b03ef1c9d91e5fc9bc593:1": 0, - "8c7dd2039eea630dc040c74656ffac605bb44c0e7e712f0c82560719353152cf:1": 0, - "8cebd66ae223c28c1db0fbd9638668ae9a28c757b164c97e93a22c9b2ef9fb8c:1": 0, - "8e8c64dbd7432177be9ee1b7c73169abb39e902f98ab8f0eb5fd178b8f3922cd:1": 0, - "8ec1f1de03d4af8a3a22bc479fec5b766f4499d955def32d8aa58b8873ecf9fe:1": 0, - "93b5460b7ea21bc89819b080253996a1319ef70d635b1b62fc50151d4255ec94:1": 0, - "9452d910edfa65523f9e7c568573dc0389e675cfaf72460545721972d5ae0255:1": 0, - "981bf221a2b204dd365a198ab358d04dd04dd7315e3b8777ac5d1d13ca51624c:1": 0, - "985d8a22e4c99763b38aa6be06c74010583083d8dedaf2524105e2f84cc8580f:1": 0, - "98f23473f46137c257a9bccf55cfe9e8efb95070a8a3f3e6af02b96f0dac639f:1": 0, - "9c83959467c36c041bcfb223fecc9d213cf431ccb6931407918759f5556690f5:1": 0, - "9d8706fba0785e3d92ee79dd1ea7a49478ac595a431541b05da53acd73a9c806:1": 0, - "9db29a1b50318b2e9d68eddd92b4dd5f5eb3439a34ad360d97ea1ba7bbdf6f55:1": 0, - "9f074d00dd12453701c5403934614ec84aef4fbc191b5728386f052707ca407e:1": 0, - "9fa4b639e4603f0de8f5255acfcf217837b55351342a012b11f7f44c35a9fde1:1": 0, - "9fb0d3fdca0533f86202b9885a11a483c270994c7cb2279efd5d90f2711b8d3f:1": 0, - "9fbf42789d3d3f64765e21498102fcdd5230f83fe79b854c779dec11d12692f6:1": 0, - "a07e2bfb0e7b27dc3854d1a2eaebe322cd7f5c975b835fb244ce5a371e5fbd87:1": 0, - "a1278eda0b98ba5c2073f32665bbe6544d183c37f27e800762bca7e6af9366eb:1": 0, - "a456463094a89018dd2252696ddf6c6a5a47797a5b3879418119d6d725ab3dca:1": 0, - "a8b6b761eaf7d647ea08b3f58fdd382812bbefc5f96acf114d1be3be2ab1d94f:1": 0, - "a8f03bf9ac0311e6dba5d79fb62a881fa003b019312e49881b327b00578e1cd5:1": 0, - "ab1012eb232c0070b566b6620a8b839e8fa78831cf8216337c1db71e35b89953:1": 0, - "ab74079fca87f186df5676c62de63d8b21e98746c8c8888f108310bc7849e70e:1": 0, - "ad3f3d67b74bb4b9b4457f1ccb99ea3dad02ad24c0f4d2383ff1fd12f7c0a684:1": 0, - "ae8e62c0ec616466782a4f861d108ad06e593af6c8a4fe3d2c9ca1386fd559bd:1": 0, - "af9e9fbb8e65d724bbf483ed4b7577ff183b0e8ddba91ca40456d43b3135f81e:1": 0, - "b0d5833bc0b04e9d59e319e2f5cc8ddf382d70208848ac6a0302c675a1ddb74d:1": 0, - "b1a6dcac01ba56c1b6fe3c925946f8e2e1901a413b53fcdd720eb61572d2fb78:1": 0, - "b20a692e1d25335e608a57eca240e4a212d096d0233a98ee2ab8c6ba49b3f2e3:1": 0, - "b244e6eb9eda76eb54274d9d8758268c6f6db03257f738c7bf6d05f4fbf2208e:1": 0, - "b3081a5198913dc4292cae4d1129dc46c50019310e5a491825a80e756ce3ec91:1": 0, - "b3f2c1e7056cd993e43da5b00e634d7be6bd0bb0e9dbe9a0ed09e0ece868b879:1": 0, - "b446262c5bdcfcdc11f954d6eb102dd68e589155855cbbcc3e4e2659e8715977:1": 0, - "b481055063714d06f1a2574c4b554849887731181cb71d8964ea54cbd389a9e5:1": 0, - "b66d9c820cba1ebf540d2cd93819e19b5b012f6266c3caa6c73070fc808a03a4:1": 0, - "b8c43897c5e7b73a57ef49e244ea1b4931492ccafe1d3996065935ea6b6fd68d:1": 0, - "ba35041d494687ee7f2e821d50b1ac63728a5c56f56d41da8d508015c03359ae:1": 0, - "ba67580f9ace0b3a538e32604bc26ac5c81c1f68e46e9d4cd81cc3f9cd7019ce:1": 0, - "bad9c1fc8345c31df5af1bafc8ff4f6301be18297ce09696d9a599f2bc4a369f:1": 0, - "bcce7e1047e1b07f23f56bfc9fbfee33af6f35696ccb516bf13db41688d12d1b:1": 0, - "bcdf59b2e64912ca8d8238b476d40de0073aaa0faca249b30130394376fbe4cf:1": 0, - "be01b70f888c47b2031a23e85b7ded702c99fb6371e7b60255ffe539457973d0:1": 0, - "be604bfdca96a0e4d268f3b7c0a29820daabbb91192a79fc5819a2c012f312fb:1": 0, - "bf4ef5a431d9906c6af59ac95371579ee8f3afefaa9bce2c1f9352109f7b182b:1": 0, - "bfdbea33b3025d53035521a218ade8c796d0c780975b8f79c09f9e9253100b8a:1": 0, - "c02bb5cff013bf0749c345fbe1144fdd18e759efb3eb5ac66a68f2012c77f974:1": 0, - "c476aef94b8840902a97cd38d37f966e4338a72ed79540c6ff20125b32cbf038:1": 0, - "c626a9ebc81aa9b6b75bab9563a03c97846e4eb975a98e4c54ac1513d0f87952:1": 0, - "c973d0ca494fc62c06c53bb0638888c0c776552deea8090df707f464346d7780:1": 0, - "ca39c397ccc1ac2d8fcb175cc47d7377c8b7f4aa6f1366d5fc027009cda92d51:1": 0, - "cb48379c00d6ef28a3e9a7c34cb444f7f068b35345186a7647194abc715a39ea:1": 0, - "ce3590e5e33a67073e096917e4f25dcf6efc983a9f952372f131f6059455ca5a:1": 0, - "cf23a22b693e4f3b3714ddb721fffa981f92d47420686bb814abd6014658abd6:1": 0, - "cfcc6767d5b0cb4cbfb1f7aeca7ccdbc1b317f175222ffc572eb491f1c619264:1": 0, - "d0f079bf3d4b490a76aeced6ec27790372f5f11340795a4e31d41e47bda69a13:1": 0, - "d23b0b7ce81aae0c3b2f3669648eafda68dc13ca0111347dc9960465f5fe3721:1": 0, - "d2f3887a130095e9bc802ceda8ed0b83c51248c1cb51b896cb966ee3a9638996:1": 0, - "d374686da4ab49cbf4ba6d2589a229d80c3c1e2d5f00a867803b3c5b9e11b2fc:1": 0, - "d3e22b16fe54e43b38579578d443bcf886f13812eb8d791e36b37e9e92e6e03b:1": 0, - "d5ac3c8472df87a653bb8ccd5697d94e3525db64b3580822dfd092adc74b0cd2:1": 0, - "d5d591d9e903d63221a818ad176237129a1f04bc3df229a4bc657163730aeb37:1": 0, - "d6d173d5ca721bc35f570a95d4f3cf5f1508e491d3aa904a37b5ee693148243c:1": 0, - "da4c26adf24970b88b3e7cb5d9af70479a065172308c96aa8e721b09018780d2:1": 0, - "daa4be16f9b5a693fac4c7b9f6424c19c558e24ae553fdc218fe49b5ed3ffbb0:1": 0, - "db32b08b6da79c546fbc22829ca1a40cba90a165857e64bfe98c6d55c840785a:1": 0, - "dd0937b09ab5c56677ecc9a42f2f8907df8e6b2818497cc3aada5d90a302e413:1": 0, - "dd1c0eff0557aeaa33fcfea188ee17770a708cb810896786b90293a254b20cdd:1": 0, - "ddd6e44aa204ad1e4fca370602b67bd07395a04b5330b37ed5bbed3648ca823b:1": 0, - "de878731e9d89bc576a3e4f536a05b7cc7ddf499a61c1d2efc4362a53d6f69d7:1": 0, - "df14cc18d2b940b04984ed2a9e6b8f059d9672569b56fc4e335a300a3215eb7b:1": 0, - "df369c267b4fbc9fade52cc7dbddfbeffc824c0aa92976f57894062c0166583a:1": 0, - "df39a1ff2afe96d05e40f842e7947fd4dc965fd02276a38890d17677c1210c5d:1": 0, - "df405bdb25adc2b339de95900097b7f9b2243ef34948b7a62180856ebdbd7c69:1": 0, - "e04585ba078bda9864c9f79f39f399b7c755ee61b1e89d3b37e1a4505634ebea:1": 0, - "e0e5c9bbbe3eaf651f132ecc1a70bf020be80b85541922a28e633d579cd583cb:1": 0, - "e123277358af81e7ede30e21956187597413a63a4e31c7931dc7dd80c153079a:1": 0, - "e1cf014d84565d434119a84c746b43a3b96ca6bf1ad46b94d87e376c683b141e:1": 0, - "e4509b664b566577ba75342b678e42db37c9e34a3f7c709b380781f9b0858e14:1": 0, - "e48c53c547ce715cbc7c317db2fb47283c21a1fbff749c2fb3e5154343f128fd:1": 0, - "e6f6357517b4ad5c6c5eadd6e815ea9c9a6396eb17e592767d8ac63139e43171:1": 0, - "e7bf874b1dd2c290891462f151a78a5d21895cb4a9c85ffaf1207515542698dc:1": 0, - "ea5b5adec8f845a5a136e621f4706a4b463366b01b3bd08be379ec9159ce4f8c:1": 0, - "eaa48590e23456cff55b5984c74dea5517604c2181dd343e3708933e29995474:1": 0, - "ed04a76cc2cd870c63a2079577e4e6a03fa57da207ceb0eee5d8f4ea7040ad1f:1": 0, - "ee080b9bc0be3fef066e83f5d5637fbd45fb606ac8903285a139d8505ccddf11:1": 0, - "f084b3a6f67a553a2036f11f2d0d8e548fe8f82ff43105c6f81845cf0178ea9f:1": 0, - "f1634626cdc61a8f9db3698f98838655f88a0b5110a9302f4921566a056e101e:1": 0, - "f57bfe0a55bda9869af2c745edfe30c153f31151daf2adda97d1927c13b2504a:1": 0, - "f5a8acfbff99d5c877797148e2a62a24824b10343458ddb7771e73577ab3c401:1": 0, - "fac0e4c4e2034b9935f001cf40f773f8bd1cd020d536c8285c96b16fa5bec080:1": 0, - "fbe30acda03286f5a508f9258ec69d16546877f80a7441b8bf8162f9838e8bb5:1": 0, - "fc0c848b374836b30670aeab884e4ae89da54c13ea586e10ac2d0a2ed452b99a:1": 0 - }, - "0f43fc3c570715792fdefe09942cbc8ca56ab7de89dc48f5f5d3d40866782d6f": { - "a8b6b761eaf7d647ea08b3f58fdd382812bbefc5f96acf114d1be3be2ab1d94f:0": 1250000000 - }, - "0ffbdd9ec702c90772753e70f297139956e3dcc8d1513c5bb499605a2d5eff43": { - "380302d4d042b0d3b6b0896e98b1dbccabedc24734dada0ff7353732a61c15f4:1": 1000000000 - }, - "10c9f5284da963e6df4e4ea8cfe3777793c254dbdfbb1d11fcd6fd536c4b1ab8": { - "be01b70f888c47b2031a23e85b7ded702c99fb6371e7b60255ffe539457973d0:0": 1250000000 - }, - "1171419f97edbad635820297162b9642e8ebf92577ebf2594b323bef32c95702": { - "1caa837b9b99f86cc5d0802d2da24bc4482138e10a280c92e85e8b80130dfdaf:0": 625000000 - }, - "11ed0e88ab2dc824f7189046d0f422644b7ec36a3705d923ea4d849d335d565c": { - "df369c267b4fbc9fade52cc7dbddfbeffc824c0aa92976f57894062c0166583a:0": 1250000000 - }, - "13d1f507f3ee71a29957286e15459ffb1bfad6cd55b319678d8607c1071a0038": { - "d5ac3c8472df87a653bb8ccd5697d94e3525db64b3580822dfd092adc74b0cd2:0": 2500000000 - }, - "17c1600dd020879c3d77a7149514ea4cd18a8bce1e4642c37a0176ae5c8e9c0f": { - "588d3d3972eb0ef218e66d150888c10550320cc4fe1d2be41446a0fc9d7b9822:0": 1250000000 - }, - "1a4bffdddab3b51cbfd375890128b8084ee882680e43c03669101b6c0b3fc30c": { - "747d4ce0c1365c99205ed6475451083149bd2764b28543775bae77df6e892649:0": 78149000 - }, - "1a715bd70ab4d24b4d5883d5a5d91a13243820274d946deb84cc754c619426b2": { - "411e92637bb0ec8c71ec0bc892cdc5b897d26dbd03ba437ffb57db58ff320894:0": 1250000000 - }, - "1a8a3ca8b40d32838a5e6cc4c7ada79146ca45af90b25011cba9b4e0919fdede": { - "e7bf874b1dd2c290891462f151a78a5d21895cb4a9c85ffaf1207515542698dc:0": 1250000000 - }, - "1ab61a9626b230c3953411e6aff92d75e8d822d0c69c34609f3d43b2112915d9": { - "c548de3c0e2cdf560345d5cf010078242f096a278c5d93bdd0331042a810447e:1": 1095306 - }, - "1abf519f15dd63dab3a60cf3cc3aa53e1c69bb97e30c3673b76be7b6755ef3d6": { - "be604bfdca96a0e4d268f3b7c0a29820daabbb91192a79fc5819a2c012f312fb:0": 625000000 - }, - "1b79e8e8f99a7466af17e0fb4388ee9f37fa17a3f7c0c05acfd45b378180c234": { - "282778a46251bd4de17361ee948d6ca274ee0668b190398ca85fd939adb59d55:0": 137793395524 - }, - "1bc529ebd7935eb9c2974cb827f889bbf009913f7898bdd526f3570919c19315": { - "b0d5833bc0b04e9d59e319e2f5cc8ddf382d70208848ac6a0302c675a1ddb74d:0": 625000000 - }, - "1e73e33e17e5a95f400dfc5107d689a2d69b7aad74089d6f639f049f0e5a7efa": { - "4ecab20ad3b5b41255fb7bd8664aff4a79c66081201d13e0eb138038b4f016df:0": 999999780 - }, - "2323c30ff2bea543f2b3f0feec2d21faa8269516b4712466b0eadc8f09a7e321": { - "3e3c31ad2bb0d69050a4880982e1884ca56be999b14b53d14e4c60524dc224de:0": 625000000 - }, - "239182e0c7ac9d5373c584d12a6dd1a9e4be6b6b9660e53a3dadb07853e79760": { - "565673db2879b2a23d8643e394bb609fb41ec7ed9510e3a0664654c529388177:0": 851023 - }, - "245b0eebdc73c92486737fe29dbb5dd2459b7bd54b81341b1cc2109004d5e621": { - "c86f2dc7a83d56fce03e80e289151ab23279e1c01be9bf2a770dd2528591a8a4:1": 2000406 - }, - "273854868c54417cf575fc3772e93a84eda0f9883dea498f5cf2b6360349f95d": { - "fb650d0b87814550b9cc20e8fdfc72198bb13bbecd74ad29d9849aab039d0256:1": 198800 - }, - "28277b5fda16b8ffda37132f0dc52ac63c66b617a991f45e07f532aeced29a65": { - "37c7f8e63152bad6ff3b704e4a60985cb25d877606c7c0514e7ada480f99ef1c:0": 1250000000 - }, - "2af3432f37a3f954618aa81284b8c7ffe9db0182195f33f1763c3777d040ece1": { - "b3e3d7cba32180e7a72a2e66126d6b2bee9e386788ee099c5f032efaa8772fe3:1": 0 - }, - "2dcbddc79fc597e93de1142eb9f1e590e2829dad703e21e06d4db9422e081400": { - "ba35041d494687ee7f2e821d50b1ac63728a5c56f56d41da8d508015c03359ae:0": 1250000000 - }, - "2ec3721879622229134ac4c2f8861238359b3e94a116c1d13aefb2608e379950": { - "dd1c0eff0557aeaa33fcfea188ee17770a708cb810896786b90293a254b20cdd:0": 78125000 - }, - "30b82d9b0fb07ac5320c7ee3470445e97d89ff0d71441edac01831c496017385": { - "a89cb324d4efb56f9344fe323eba15835188e1eb6b6d52ae69cf2e3664fa3fc3:0": 4624625 - }, - "319c30afc9ca6689689ae3202c6c3523a02d382ce3e039136b5792536ff31fd5": { - "34a279c4278bbf42736f5345d5882058abb0a4cd7ba8f9ec3c094e9dc9ec008b:0": 1250000000 - }, - "336eb46da8752b3a188f69b37413194fbc1c0975cc56d2f4df0ded0239e9ff34": { - "93b5460b7ea21bc89819b080253996a1319ef70d635b1b62fc50151d4255ec94:0": 1250000000 - }, - "34bbe22a5acc3a0b73c453a1568dcd46b6b23c32f4bda6d067d1f44b0cf42bfd": { - "3d2f9eb3a839a98bb53c6df9b9df3af9420f487955329009ebe6d5ce35c0d7de:0": 156250000 - }, - "37296baa8e3f4146fb9fd3aa3a360eab55ae4a5bfe9e359af5aa9d914b201548": { - "a1278eda0b98ba5c2073f32665bbe6544d183c37f27e800762bca7e6af9366eb:0": 1250000000 - }, - "379fcf9e5892678925ac91da0ba870869f05da9e00267fea14eb36e109eb8554": { - "59302675cbb9ff422c5c6ac365a1a4f2798e9183c4f027463573f0cf1ff9fb26:0": 625000000 - }, - "37d23df6248be39739c0453c4700614acf906740f1335ef4ea5519efc93065a5": { - "b481055063714d06f1a2574c4b554849887731181cb71d8964ea54cbd389a9e5:0": 625000000 - }, - "38a5ebb4e17a5bd2a724aab33dd7096415d85828789498d8e0989ddd9ca7b3b7": { - "f44e4d36066cb632ad90009562e2bb1d6b8e756a1a3ad7ce3640decf5384fed3:1": 608693 - }, - "3ada300ec9b6a1e9774ed7dba6cdec34910ebd5efb9f0db9d71440def125ffee": { - "1b080dccf6a653d3177683243719ebe9eefd62fa4c65ecb040b9b315bef7d54c:0": 156250000, - "52803985df4cb02bd28ddbf089671e5ae130b366bc348fcb4db0002c3d2a0756:0": 156250000, - "62581f3d27c05dc4573a7cd31afee05b6ccd2209953cd924a171cb1021cec290:0": 156250000, - "727450a0d92fc99255fbfb5de85245b2387314f347f12762ca7e3aafda0ab5c4:0": 156250000, - "b8c43897c5e7b73a57ef49e244ea1b4931492ccafe1d3996065935ea6b6fd68d:0": 156250000, - "d2f3887a130095e9bc802ceda8ed0b83c51248c1cb51b896cb966ee3a9638996:0": 156250000 - }, - "3d7d8bed847e041d1f040a560c970f6225bf345bb6aff3a81f8935f45b46fdda": { - "9c83959467c36c041bcfb223fecc9d213cf431ccb6931407918759f5556690f5:0": 1250000000 - }, - "3eb23bc89e3afb91c3f7d67e7737d26ed00986a7072e806788b3b2b9efd1a4ef": { - "8c7dd2039eea630dc040c74656ffac605bb44c0e7e712f0c82560719353152cf:0": 1250000000 - }, - "400a4d4df9ae3ecbbfffd259b000c2c04f7a9c002a7abeb09e7a594e2bab44b6": { - "ea5b5adec8f845a5a136e621f4706a4b463366b01b3bd08be379ec9159ce4f8c:0": 1250000000 - }, - "407adc321d336489d8e9e145062179fd285bb6a96cc294cee1d42d19fffabafc": { - "506e0e953ba217afd18465980c16438947d86ebd74959c689b3b53d1dc2fe2d5:0": 1250000000 - }, - "4217a3008ba03667f8365cf6d8a143a0cfb0995edccf666d7a7cdd5701ffc3de": { - "79e66107ee8ad8a0c93b34ca80bd83c33efdc1eb25dd1ff902853bf77ca39cf0:0": 1250000000 - }, - "428a5784a0d1b0c8b2636352b0cc69dab5b228d67f8e79f24d71cb75945820db": { - "750b38d81dbbb4eda455c2fdd27bb746c9eadd390034fc184797aceed6b1d20f:0": 2500000000 - }, - "432082fbfd6fe9409959a6d01e5ce0d6be83775a35c6bb796b71337cee87924d": { - "757ddbf9ff3cf065bb602a13356ffed5374586ae897baef6a2b72386e8b8aaa1:0": 1250000000 - }, - "45c398b20330c812d1ed43a674192926fdcd940153c2519b4a93513fab0823d3": { - "985d8a22e4c99763b38aa6be06c74010583083d8dedaf2524105e2f84cc8580f:0": 2500000000 - }, - "468ceb811e9720bb4f4232e5ad4c9301c88c7798b4875fd567a3d5a4a2ef6c17": { - "b209cef8863e4401c96eb527445ddefb18d9e515cb6551936f6e55c331d53809:0": 4624625 - }, - "474a39c86e078d175c0159729d5ca68d0f22eed36d1fe2d89dc28eaffaf27346": { - "856f4b1a6c2b8055e6b48686129878c68f83e0444a00430cb319fd7186e9c1c7:0": 1250000000 - }, - "4798306b08b6046a3a632a488bdb6de0248b22d74b57df968c53adf403507d6d": { - "0a9743ce1cd227e12b247204349c7e5f801059806543e91fcbd1c6c8e70159aa:0": 999999890 - }, - "479f49158924a137f80f41628743ff5df6f8d9e4ad3846ce8e965923a8d9ca35": { - "2c8737fddbcf0d130dcfb72dc0fa000a7dc70dad88dee6aed2acac1320e8fd06:0": 1250000000 - }, - "47a9f5a7f3ae3d676cfedd5c49ba8303387aaf935a7b5bdc6521fa05eec22a1c": { - "de761fd6c3c65de80fd53aadfaac5558eb5fc4c1f2f0719da2e69ffc81e1e671:0": 1059703 - }, - "48a92e9c6db83c0a1cdaaed3b7cf5af7612eb45601743a395f62cb9b0b07d45c": { - "616f81a3dac9b300c1c95ef4353c30425b29f73cf6781b89263890da79278cd1:0": 1250000000 - }, - "4abbd34e78a7be48aff65542d46d4754a854b610a111916042ba6f2e12a79a09": { - "683425171b094d3e222a7eb6327e2276e8a66f02314d57cd3c50271eaef8d3f9:0": 1250000000 - }, - "4bac8727a721d8e52a415e7acfe6286bdc3b215961b806f69128132e295ed4f3": { - "fbe30acda03286f5a508f9258ec69d16546877f80a7441b8bf8162f9838e8bb5:0": 2500000000 - }, - "4bdb59e1f8af8d2fba7994c1c24b7f07c363a9bf6fae446bd885b242056e0493": { - "3ffbe82458b16661b67f0dad94cfaf47c5c1b429906052eaeca6c09b96cc48d0:0": 207235760153 - }, - "4d64b0d358c7405bb5b7dd11efd7a7e74845480a3b0d03f2670524fa7177ab12": { - "407d783397625ee8511d287dc7196ae6bfb9ec7e86ee8f37d8ac35c27704737e:0": 1250000000 - }, - "4dbba9becda35f6c232ae4c78025b590763d9afd58723677ff604c7da7fc7a36": { - "030a1ef891f4a7179a5d050fb5160c361ffe7a5ba72211e4181de177224ea25c:0": 625000000 - }, - "4de9212e10f95865673b9d5074259658c180fa6f7f0234a0ccae03028a28678c": { - "bf4ef5a431d9906c6af59ac95371579ee8f3afefaa9bce2c1f9352109f7b182b:0": 1250000000 - }, - "4e65bff6c400ec01d998ad4f62693fb1428e2c66d8939725bbc409bbc7cf059a": { - "516145bf354893ca80ccfaecf05620a4d99698a515d135cfbfc4e12f80f617fa:0": 1250000000 - }, - "4f575fbdcc5b27610c54d57c831546ac912df57f4e0347e14038bd706c604b45": { - "981bf221a2b204dd365a198ab358d04dd04dd7315e3b8777ac5d1d13ca51624c:0": 1250000000 - }, - "4f9b1a579787d7aff1d1cd3794e946d1aaa3dde908407d32189e722ff9b400cc": { - "32656b80136626aeef1f136f9e9d2a2a480df0de7b69a195441ab611008abaa3:0": 625000000 - }, - "4fa1342858fd1c88fde6982f386cfa7dcda97e44db38e03a6b2963f38de4b4d4": { - "c476aef94b8840902a97cd38d37f966e4338a72ed79540c6ff20125b32cbf038:0": 1250000000 - }, - "4fe8ebd6e54f0addcb319ef18be30d09833a4a0af34a2a2f37cb0768bb8e8a8a": { - "7698e42487b56fac8ef417a78e02af91ec0fceb77f5f45a5e8060982bfe143ae:0": 1250000000 - }, - "509d83c8261c63499443558800a005f5bae435a3144fc75b81cddf07859e04d4": { - "a456463094a89018dd2252696ddf6c6a5a47797a5b3879418119d6d725ab3dca:0": 1250000000 - }, - "52020a63d3c2b1fd4ffc6b53b889acce74baf80cbd3cd003de4d0927f1fe2109": { - "1b3dcbd7bf37a23b56ee9db1182dff5e4cb238bc112b52cc3059f037c44bcac8:0": 1250000000 - }, - "550cc90f31c95d195768691df3b9eac7d67cd37215ba9273dd97379dce526f40": { - "e0c901fef09db972399e8d4fd746c881bbe9a600e9605a27c5f0de8c36e8da9e:0": 565403 - }, - "55f9b4a0cb65b1950af4485663324831b15c810f3f05bb5beb8b9ce5f5cbdafb": { - "daa4be16f9b5a693fac4c7b9f6424c19c558e24ae553fdc218fe49b5ed3ffbb0:0": 1250000000 - }, - "575f15b91b7e42cf032252930508b25946ebcb36a879a4773f5c6385d02fc26a": { - "bfdbea33b3025d53035521a218ade8c796d0c780975b8f79c09f9e9253100b8a:0": 1250000000 - }, - "57ef949d43e1db66f05eea95164507390ee2aa5feeab79a7778f754109d1fbf0": { - "02dfb7815efabe1183ce34908d4a6e6a3f25495ad5f57120640843d418479e7e:2": 137794562402, - "065cac0bb30c763b66c25e890be537589f24cb90219bf30da96a5d37e051e052:1": 200000, - "0fb9da0e14c71d8b85bf00d23fa3b599972ea48f94e567e0f9733ed7357ba686:2": 137794696378, - "1e4d59e4cc5b7cd10be6624dd46bcf90cce2fd6320fbd496a71117cf82233df4:2": 137794662884, - "2ab0948cddb04221e6ab88d2d9ba5ac11db5e5d1e0ef89ea8cc69b19a17dc6c2:0": 2500000000, - "335d73d9f9295b283a07187652a587830eb1eb2ee2829ef566dde8445946e474:2": 137794495414, - "4b04207325babc3e30db43a780637e65263082ac655519784c9671209953f7d7:1": 200000, - "5a5a803fa15d65a9cbfa7f72622af5d43e1027cd15ce33ded0fb7dd84d5aa0fa:1": 200000, - "5c6d7a19cfb35a98949a25c5fede1dd798a7e1509e0067d000319126c34f8c15:2": 121874421900, - "697895649863c50517092f62b47284ab00dd812a100a2ca6ac9469b5e4f6da2b:2": 151949049600, - "88a56990a69eb1a7c11595b7a88097d799fb6070bb2f77ea34e7f2fe8d54d867:1": 100000, - "8cebb4ea3a63829a2787c3a473f14b4f73ced07552f73ff8c2b1152169042550:1": 27013422104, - "a001edc1d43b5b41adc5a4c5ce9b6edd9dcad9fa3e50cca287d256a23cca9d4e:1": 100000, - "a1aee934c5dda700d15934667507f90db1ce36d7dd3068165b54159df320cd02:1": 100000, - "aa407bf21c31fa3ebe8bf074767bfec17df01644da94b3b709c3d3551f3210ca:1": 100000, - "b28b20fb9089f2d021d812dc34ce461fdcd800137434edaa09160a395c182c8c:2": 137794595896, - "b6e993fb2b0584bb35b8eefcfbe6ba7c9b9b7875e69389063c96f78dd1fca9e1:2": 151949105100, - "bdaa76d37810e4d4ad1d2e7ac6e198ad96c48683e552e874901a7af3d8ae4e50:2": 137794528908, - "c375a00e0ab9e41d86d0b2e2c794da72160c7437b79e0f4a51ff70814e760c01:2": 137794763366, - "d4495edb0266dccdc77a8f1f6899f0921f56ba14e80163578d122dfd9f0251c4:2": 137794796860, - "e216f36276646b01f5e5af7bced7765a18870e8b5a7b0827938cdac8b5d97144:2": 137794629390, - "e2d2f23a7291b8cdbbffffc516e433a30c537f3c4f042f9a5aca924d57cc0ea3:2": 137690663733, - "f1c2d97b87ec7ed1b58e8b2b70dd3263e958e6b02a45477ef9b2c5f98f53df25:2": 137794729872, - "fcbee4c653cb3695c31817b0fb8ca1287fbb67fa8ad72801b520eae222878536:1": 27013431768 - }, - "57ff8d0659990caa8043ac2b7ffec3437e699d24f9dda144ea3041d811faa2af": { - "b1a6dcac01ba56c1b6fe3c925946f8e2e1901a413b53fcdd720eb61572d2fb78:0": 625000000 - }, - "5846748d35cc1f8b447cc00f0f3dd3de9d8c6647f3c74e736dfe2c14ae9fb22d": { - "42a1d1b8b6fbfa6b9f1158c2663e00c023a278d9c587901a40ca77fb5835e471:0": 1250000000 - }, - "596e445351c65bcdef2637da42474ca7e283a1103b1366e1afc548701f68fa3f": { - "6672bea8ae29f1b2fce4fcedf95ad863f69291d5ff2d8e2c3a58d9d1370d5ebe:0": 999999560 - }, - "5a5b19468ec2a7ce43bfa32b7128296e6f08fb01a0113fb50ca2324988b901ec": { - "83d64baa165f011c9c6902880c2fefada3df75c1271a51da205e383cc6d58327:0": 1250000000 - }, - "5ade71a6ba5391a56274610d4d4a045d976ea5b11660cc6112a1bdba4de5f868": { - "eaa48590e23456cff55b5984c74dea5517604c2181dd343e3708933e29995474:0": 625000000 - }, - "5d1aa1f4dfd06ff863908e1500a03ea37d62dd66315864da512ab9c744ffea0a": { - "ee080b9bc0be3fef066e83f5d5637fbd45fb606ac8903285a139d8505ccddf11:0": 1250000000 - }, - "5dd0e8cca111d187a4726096976febc603cf5564ae9db4af100c572d0ca27b43": { - "db32b08b6da79c546fbc22829ca1a40cba90a165857e64bfe98c6d55c840785a:0": 1250000000 - }, - "5fb4dbd05379842b11a048ee73467fff68ef04500bf59e0e1a0388474b0e259a": { - "af9e9fbb8e65d724bbf483ed4b7577ff183b0e8ddba91ca40456d43b3135f81e:0": 1250000000 - }, - "6232f0790f4bc993b8bd042828a1f626b0ae3ac20b1dcf5a479be57c1035de29": { - "316b0ac8e6516b59633a46dd5f4fa19da180f799f871a57085f6a7e99bb561e7:0": 1250000000 - }, - "62799d1ce7da61b1cb7365b069a2d7b625946d611da5389523aee032edca62a2": { - "5d9d23e6caf4d2bdeac42ccb33d1c867f5b356cada6f4a301dba292354909a62:0": 2500000000 - }, - "628411a7830344129fb00a524c6887edea861e5c28babafc13be0aa7866cff57": { - "6f61e5d05d994e581c48770b5a9800230fa3bd4aac0b3823cb9f3097b36e5297:0": 1250000000 - }, - "64106030d474008dc66e0da6ea8527ddcc63e770fda71c9ff0c9527d20d9359a": { - "581c27a00de5773b917c838e7060519b4502a40522e3fb846a8ded340d1f72e8:0": 624989000 - }, - "64174843d3cefaf0ba87b868ad83571f28e28a3fc84080f91dfc8cec5ff6c17f": { - "6a52b7fb5be6115a7d4283c8a3b4769a17de45e61eb4925e99e8af930c4c4ae5:0": 78125000 - }, - "64bb1a1c7550aea8f615c48af98e2ed1ace6770d84a82da96e661621d67f27a3": { - "0254d8a8a3e3a8389927332b075fb8bd190f6cb789f5bb3941dc1ce6581e476d:0": 1250000000 - }, - "64e1774204e60c3501fd8beea19da7ab3caa7ad7ac179b19356f771926e027a7": { - "c649c3c5b1d785a00e4bb839c5d0bfb6b1190cea794b703dd3472f3134e9bae6:0": 207235738043 - }, - "66ca3e6a9d403be8949dfc21d21adfa343cde04c333d1055343995f56778c68e": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525:0": 100000000, - "1cb59087e772b43952f6a7676b16880dc9b9f16424c31baa3d20930b6454e39d:0": 100000000, - "2a1b54803e6649bb49778d2b84b542c5db10351f075941c7ed6453e0c152ea39:0": 50000, - "360c08390022019fdd20d6c27415592cea239e8617bd97e6ff4209a317ede43a:0": 100000000, - "41a7014c7cf88df492204f9c01d3f1f6da27689514c454648efeef38b7a70716:0": 500000, - "5584a54ac4f0d5b1456e336c6e8359388f8a378842b535f7acc19441c42dddd8:0": 100000000, - "5873c9a5aa8e854f985a0ff86178e734eb100f17f4cdaf18bc0305ff398af419:1": 1000000, - "62cb262f03d307f0aecccb38d39fb6989913abd6753f7a9652876840d7822de6:0": 100000000, - "6e812ee5a44c2d4428d52e65854cf63f7c2cf02e4b7879132add5a86479b9267:0": 200000, - "8b0704efe92f0faf3d586e9eab68424adfdaaa66aeccf8207f40b50562e2e410:1": 500000, - "a262585a6212f09eeda8aecd6005cd6c0518a683fd35e9cb960e64a59f47ecac:0": 100000000, - "a38401662d29ba7ee2ef9857996246d73401879b2c458b18e862a035ab6d4d50:0": 2500024001, - "a89cb324d4efb56f9344fe323eba15835188e1eb6b6d52ae69cf2e3664fa3fc3:1": 5000000, - "b209cef8863e4401c96eb527445ddefb18d9e515cb6551936f6e55c331d53809:1": 5000000, - "c548de3c0e2cdf560345d5cf010078242f096a278c5d93bdd0331042a810447e:0": 500000, - "c86f2dc7a83d56fce03e80e289151ab23279e1c01be9bf2a770dd2528591a8a4:0": 300000, - "d0af040eea426592b29f4de05650e6f9659ed23697272a5e2e45e18970a59e94:1": 1000000, - "d4beda4e362e9a6c393af904fc0f6417add96b81566514e37217c017ce5c6a94:1": 100000000, - "e537bf41df80639d8fa4365a01782d462e6d70a1eafc0d62d33ae05665ca3c7f:1": 1000000, - "fb34ed0517114c74cd07dcbb7801f6068e57a0c8d69daa0440650f32a7e6d0d1:1": 20000 - }, - "670557abcdb1b1cd2a8407aff8f0278ee86e318b3a6a371fff8ff4ce61e0ae0c": { - "129a01db49fce205b86d4774ac012c1bdf4c5c402925a4739dd9797e424ae58b:0": 1250000000 - }, - "6795e746f708a48fa8ef067cf27226cda6e374c47257851b81ee0d2256bfeac7": { - "f5a8acfbff99d5c877797148e2a62a24824b10343458ddb7771e73577ab3c401:0": 625000000 - }, - "6a19b23c070d4886a9782bc0fea82b20c8f50f5c05cd24f50dcc62e55a03f16a": { - "7313ffb6366bab4cbe68f014f11e3608c308a44314219bf157a5c3167aecf254:0": 199090 - }, - "6a98c2eeb9c97bf4c41c14f1607a82806e65adc740f682946ab658e7434b5af8": { - "f57bfe0a55bda9869af2c745edfe30c153f31151daf2adda97d1927c13b2504a:0": 1250000000 - }, - "6bdbf918c905f4394c1a4293277464e60b162ee19371e43dcc1fa65027bef8a9": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525:1": 2399999859 - }, - "6d943661dd257884be6ad7ef2271fbb74109a0f4da80767944365997d6bb7838": { - "9452d910edfa65523f9e7c568573dc0389e675cfaf72460545721972d5ae0255:0": 1250000000 - }, - "6e7f3fa856dbd50a5dc89e43874bafad160014cfe2134b9c717a1ef497cbee55": { - "a38401662d29ba7ee2ef9857996246d73401879b2c458b18e862a035ab6d4d50:1": 0 - }, - "72103dc7ca0c0f1a3528db93bf750b2e445036248f647e03d8b5f87d92082c55": { - "28837747eb6a849543f5f03063f1436fc44f64f028448022ec0c382ac5bb7c31:0": 1250000000 - }, - "725f3e6a33ea2bc062ed354bdba671eef14055238a4c1b49d19f11885e8ae4ab": { - "9db29a1b50318b2e9d68eddd92b4dd5f5eb3439a34ad360d97ea1ba7bbdf6f55:0": 1250000000 - }, - "72921ad8584e5ab63e107c758a20a21b5a5bf1a2ab19b0a92abcf03e71db10f1": { - "8b0704efe92f0faf3d586e9eab68424adfdaaa66aeccf8207f40b50562e2e410:0": 1736306 - }, - "7a921c1c5dbee3ca3c36688e1248f6ad68da7cde8397beb81bf7078e987289be": { - "190baa4d3d24145ae21e16b9786bb15452ade631265b79c9a30663b8dea66c75:0": 625000000 - }, - "7a945d25ec9d80886b788145b14ebb7864c0195c7c7c0064a6420beca9e0b51d": { - "9f074d00dd12453701c5403934614ec84aef4fbc191b5728386f052707ca407e:0": 1250000000 - }, - "7bdde6ab304866cc89c483ca8ab7cb73424cc0140715f9d1a7c4667e57d23702": { - "ab1012eb232c0070b566b6620a8b839e8fa78831cf8216337c1db71e35b89953:0": 625000000 - }, - "7c75fa589617b7d68ae61df00bd9d4eda1a0fb64117d49d27e50aaf9fec52e1e": { - "02b85518c4b0d2c17d43e88cd62552b0ab8da2ce0605ea3a8b3cd70b0395c6b6:0": 610351, - "0b2d3c47e2f34a34baf07ed83308aab05101b3afd3a0494167e6db0c46bb7bf8:0": 610351, - "2a35cd9588ee727bc532bcd185d8ef78d5d54ec168cdb2d49516e8042ddf05fb:0": 610351, - "41df75627f82b7db83eff2f542ad814e0e013bf44d4f67e66414d3a6a0ecb3b4:0": 610351, - "561baf14fb75bf05ff0222d5294d5332f500a9c9c4f5948eb2903120ca606aaf:0": 610351, - "5913dcfb1843ffcc113fdea4df6be0d5fe6ec1546f54c7387ae85d22e1062bd5:0": 610351, - "61bda73234fc36ba88dbf8a5281e419f8a2de1d41c91c2c61c0f03bb48d15b34:0": 610351, - "68a99483af54769940b80095e83d95d607f9c32c2d03c0b89a86cd4143d65eb9:0": 610351, - "8cebd66ae223c28c1db0fbd9638668ae9a28c757b164c97e93a22c9b2ef9fb8c:0": 610351, - "b3e3d7cba32180e7a72a2e66126d6b2bee9e386788ee099c5f032efaa8772fe3:0": 751351 - }, - "8017a32067302648ad152b7a87fdb1c2c4f19c174a5240e88666ba987063163c": { - "1d30124dd0acb5c11a2cb8e607c50b5f402a7be178cafb264747fb0dab37c8cd:0": 1250000000 - }, - "8054c1df5eefccab29861460530ba962861020054dc7e1939077407c1759ae94": { - "cf23a22b693e4f3b3714ddb721fffa981f92d47420686bb814abd6014658abd6:0": 1250000000 - }, - "81316109befd2d543582d68b9cbb65a9c1bc215d16791b6b546d50134aaac363": { - "ab74079fca87f186df5676c62de63d8b21e98746c8c8888f108310bc7849e70e:0": 2500000000 - }, - "8149fa7c3ec75c7ea3075adae72f7d7119acadae6af68b90d7c1174a669eff22": { - "27e579cf0329d3a7ac6ee3cce36de4532911829d71bb2c6bb2c17d3084fd4326:0": 999999450 - }, - "8206833de2ac45d066b04a520a4bb776b8dc473d7d9be96a7c4c2ebf0a749786": { - "13c4b8b888b4c7f68076855c11d51eace7ee8d6c106142b493cf47a65a8f5be1:0": 1250000000 - }, - "82ed589a64782d56035f4e078be37f54d85f23bf08dab9bfc5bf95f2c79c211b": { - "747d4ce0c1365c99205ed6475451083149bd2764b28543775bae77df6e892649:1": 0 - }, - "841f2b151937ebc42ffc5cc9db6f95704d8c5e9abfb85c8b6f4e30f240746985": { - "fc0c848b374836b30670aeab884e4ae89da54c13ea586e10ac2d0a2ed452b99a:0": 1250000000 - }, - "846d1136b7b0524d4c5378df5b5814ae57450fd6ef3e5821181e118ce44f5558": { - "a262585a6212f09eeda8aecd6005cd6c0518a683fd35e9cb960e64a59f47ecac:1": 1149996475 - }, - "84ae9b55b27bd5755fcbca7d31a4041d401cc258c452a31869981017dfc06a3b": { - "64f40121401bcfa201bf59dc9c8d23e2fe89da362685bf64694eb98f6f4fc991:0": 1250000000 - }, - "87fb888a66b5ddd7df5bc7382f1df18318ace81517ee391eab071b9df8eff7b7": { - "9d8706fba0785e3d92ee79dd1ea7a49478ac595a431541b05da53acd73a9c806:0": 1250000000 - }, - "8930ea5da3521af81d6cb3be131ddb913586a20cee9faea55632e22b64a06183": { - "0b34c01afa7c05d19c442b8a976679a3ba3c5192e2ea155d83ada16630dba360:0": 2500000000 - }, - "89960a67ada549fc1f7d7c2305314ac3606e0b89568d1714d4d8d64a3aaa9a9f": { - "0ef3b8b6a2c3d28126a7c79aa95b5bd1f091b7eb8c14c20fd5ee89c409462c94:0": 1250000000 - }, - "89d5b512662a1ad5bc162ca97e0de3106e51cc1c0ab7019e4e2e5de171c57582": { - "360c08390022019fdd20d6c27415592cea239e8617bd97e6ff4209a317ede43a:1": 1149996475 - }, - "8ba1f91080facaf220c355e9a538a994719a36dabb4ab2939308f7d95d048282": { - "ce3590e5e33a67073e096917e4f25dcf6efc983a9f952372f131f6059455ca5a:0": 1250000000 - }, - "8ccd6ba0f6ccfc1d47ee5da26c35e73c9a806f223613392b0dedef5f1b79e6df": { - "d23b0b7ce81aae0c3b2f3669648eafda68dc13ca0111347dc9960465f5fe3721:0": 625000000 - }, - "8cd08e457597ad4e7c329e97bfd83fca4523e425125331c421f8a540824c1578": { - "642e892416a039224ad50e987bfbfecefee57103dd39c6833e2992cebf8a2e8d:0": 207235738153 - }, - "8cd5515f0e402100c0ca803de293dec43e091887955c576a56c717de57bdf7cc": { - "37b933d6b0d52746412a4bc06d9e7bf6b15cd42cafe8938f6f686e416547322f:0": 1250000000 - }, - "8d44b332617e49d330d9439a2eb0507b12aca17db51120aed6a126957baba8aa": { - "176c67ae60460acbb2ccd663b0c6983eab6766095870ca6e6e2ffb77b310316e:0": 625000000 - }, - "8e4b9af2a31b710dccb4a763f359864cfc5df6e85d54efef131c122cac60c419": { - "44461d09d9fdd865944d6557405134f19cfd817bc293270223c7a98c862d03a5:0": 1250000000 - }, - "8e64653619b73fbca708a5a62413af9d54c5cc97a7425322b1886b5fdebe9f7b": { - "9fa4b639e4603f0de8f5255acfcf217837b55351342a012b11f7f44c35a9fde1:0": 1250000000 - }, - "91e215700acf0c94a3c78a46a35161a014c1d6dadd47fe4fed7ba737f7da3f6b": { - "870344155014b8e1f8b649235f228c8b4833aef7265b03ef1c9d91e5fc9bc593:0": 1250000000 - }, - "940cac339837acd1a2dbf6a2a6c0e2b43cbec496d81e4570c6118b87d59f2746": { - "604fc422eb5539fad96b6f6bca4493a3303ab5d8ec349d7668df3e7f74b3f0db:0": 500 - }, - "9452a9cf74ac110130bae41a364411464e14fcf9c803dc8c640f5ae0798631dd": { - "5584a54ac4f0d5b1456e336c6e8359388f8a378842b535f7acc19441c42dddd8:1": 1149996475 - }, - "95f4c10c5da0e35527c126f04f3229ee4d2e1d6df697f7b126897935a14905a2": { - "b00e9f65ca32f0ab7a05666f21a78b461623c43d1297a731f09a807f2c9dc497:0": 78125482 - }, - "97ad2180d9b8db98aefe25c0dedbccd8fd9f3b241417cadc54a417987fa081de": { - "380302d4d042b0d3b6b0896e98b1dbccabedc24734dada0ff7353732a61c15f4:0": 15078000 - }, - "98a3edbd711be7cc7f12cfb13e7d0b9341fa703ee33ad61af53f0b20ff4acf55": { - "83ff9433dece51bf3a37f60c81e196fac96cef88c587a2f97fb44be490482536:0": 207251786301 - }, - "9a82146d5aa15e12073855dacd724a5263e1560f680226a6aca70a677d437370": { - "593bb86df1956612d130a5aff27372e6d4c2c5d56887a4ffa62a06a1d729df37:0": 156250000, - "59ae2371837bd090bed2ea86faa9ca77eccc9d74dbed701f9398434986a7110b:0": 156250000, - "68b9c5bddb83e4f54192b67a978f6bea1e305ec558c1042623d6bcc54188e135:0": 156250000, - "ad3f3d67b74bb4b9b4457f1ccb99ea3dad02ad24c0f4d2383ff1fd12f7c0a684:0": 156250000, - "bad9c1fc8345c31df5af1bafc8ff4f6301be18297ce09696d9a599f2bc4a369f:0": 156250000, - "e48c53c547ce715cbc7c317db2fb47283c21a1fbff749c2fb3e5154343f128fd:0": 156250000 - }, - "9ad6c33e26a809f7eac6cee4534c99285bb3997ddedcb845e9f7d708c103780f": { - "e4509b664b566577ba75342b678e42db37c9e34a3f7c709b380781f9b0858e14:0": 625000000 - }, - "9afd77e74239001b891083d736fe5c13d61324a792d806993f23f6bd25f2bc3e": { - "e04585ba078bda9864c9f79f39f399b7c755ee61b1e89d3b37e1a4505634ebea:0": 1250000000 - }, - "9d20f691cd8f62c6eae6b14557d0f6ce8c1c0daeca8640b045ca97f61ba4d4bd": { - "399231ca4ba596e68131aeb0b7c5e721c7b90a4e7c27e4848487fb0453c27d2e:0": 1250000000 - }, - "9f03d956f3cb34453e04d212c40fbda846b32336e87f0c8a62d10e6be3288cb4": { - "f084b3a6f67a553a2036f11f2d0d8e548fe8f82ff43105c6f81845cf0178ea9f:0": 1250000000 - }, - "a23dbe435fb7f2a238fbfac3e6c4c87f7c94656bc70186bd7d76f90c0aa0bed2": { - "d374686da4ab49cbf4ba6d2589a229d80c3c1e2d5f00a867803b3c5b9e11b2fc:0": 1250000000 - }, - "a27ceab719ca86f204eed02e9ed4fc11f18461a3950d9f258499bd0059bb5656": { - "d2d953e950d28c021a5ddf6f77cfb9551af2fd33ccc7473b20a31d208e7bd308:0": 207263913821 - }, - "a3b6197dc82e5c43d537e52d0396c08f3a4345fc96d181bdd12cc63ad2132423": { - "b3f2c1e7056cd993e43da5b00e634d7be6bd0bb0e9dbe9a0ed09e0ece868b879:0": 312500000 - }, - "a51a58fc1042bc7509583f46247bbf1da256eb5769921017e0dda1f2aaf310af": { - "1c6852c5b99bbe89e5cde3db9074dc2a84a3f99e85c396a05ae704058d3f90ed:0": 1250000000 - }, - "a67a7df0a8d781e97d7809ddd4279d74632a7701b1543079470f94166527a27b": { - "1c3aaf7032b56afff6eaac3043aba9d7044fa109d6969306e8a1ff9bf715e5d3:1": 759703 - }, - "a6f4ca05dbe0e72ab7d7b191b0189a7d3f3dc95e5ccd3f6bd8da2e3226f6eb4c": { - "6e2570c89ccf738ee7f67c25abc7feb2b85e1501fca49e05fbe4439ff8ab7af5:0": 1250000000 - }, - "a86d95b766c189f84cd2d3e40ff6781232a4fe00de61c2ea6cc54185773d1ad0": { - "1607656494c74bcfa5ae3a9ef9136cb5f6911b662b4d46ab4ec953730fa8c2a3:0": 718703 - }, - "a95e73cdd450a03b3afcf5843088fc243f95620b9730102db385fc34bd15112d": { - "b00e9f65ca32f0ab7a05666f21a78b461623c43d1297a731f09a807f2c9dc497:1": 0 - }, - "abece5d56eefc586a883334f14a2826de47860469b1ba240acb447d500982754": { - "ed04a76cc2cd870c63a2079577e4e6a03fa57da207ceb0eee5d8f4ea7040ad1f:0": 1250000000 - }, - "acc44399be47d497c90f741e601a389730e319a90d96012ccb34da6609df1b00": { - "5b67bf78a3b367a4b05806efa537dc1ea82ae8bec788954b5e178c21ed53b385:0": 625000000 - }, - "adf13059d8f393eb2f79ca8d04f9859d976b04892995fb664dc6d6dfa184f1c4": { - "b244e6eb9eda76eb54274d9d8758268c6f6db03257f738c7bf6d05f4fbf2208e:0": 1250000000 - }, - "af80257bd3e0723677a54960f1527412ea7ac06e521bca99aaf1e70709b776dc": { - "267eeb8e65681ffaeafe0a04616b270750f1f0a83e5d51f82974ad0c98e2c5a6:0": 625000000 - }, - "b19f89ceefbd3074d3f89ed51d80c357a8d1c59ff8406d8f89fc9eaede61747c": { - "14989ea4da2283527831cc304ff559b776a79a2a115c03ef976034f18b44c105:0": 1250000000 - }, - "b252325754232a1ad100ff70852055527f46fee45d804069e573aa55ed2f9afa": { - "36ac1d694a8810ff6e930a2f7751191e72414ea832e8fa66ad4d087f09064cd8:0": 1250000000 - }, - "b431ac5644c48f25ccd6b89bc59d3a45ebf72c34b0548117e9f3fbd16681bbaf": { - "cb48379c00d6ef28a3e9a7c34cb444f7f068b35345186a7647194abc715a39ea:0": 1250000000 - }, - "b4a73df6d2623fa2b2fc4e0562d72366f5a052f469560c2ddfe366679a8f1d4f": { - "8a52e590d4dcf4a04bb46c4064adebed67556bfcf621f7775fd85d4a9c9c8132:0": 207235760373 - }, - "b6100abcb1104f61d2fcf44629b73e7fa9a7808323533b24920383cb20867104": { - "b020ccbf483abab08d6553b7fec974ce9f8ab43654863757e3ec7694c399f86c:0": 68896147707 - }, - "b6eb46f70380683e314dcaa99ec7e9e4f8ea26d1fefa38b3f2c6f061c770d949": { - "803341da80f8d6939a6aa1541d1ffcd27445c823178fc45cf1295b9e3a167be4:0": 625000000 - }, - "b91972567d210359b966034cb9ff986d190a8c1abd178ac02f51933e15b09d4c": { - "df14cc18d2b940b04984ed2a9e6b8f059d9672569b56fc4e335a300a3215eb7b:0": 625000000 - }, - "b93ab2ba8a19b1e45fefc1503003d483a59b48f0a9ad9fc1e9b7d0906dfe042b": { - "2c659529455fdacab80781dab351553428e85f2a491866b0a8274dbedaff4f4c:0": 999999670 - }, - "b99b44ee12d7ab5a76dcf34b2b798920e38d6e97f8fea96690768c46750af520": { - "d4beda4e362e9a6c393af904fc0f6417add96b81566514e37217c017ce5c6a94:0": 1149996475 - }, - "ba7d5c51462d3b42019c1303f235225798c75bf434184d6dabda9b7745a69e6a": { - "7b5ee04a3769bc5fa3230eb0fb1f7aff9b0ff9fa70815b77d0cc828bd8e51303:0": 1250000000 - }, - "bbe75c20aaba769072a084defff72cd0139509b221a0f38ec17ed1a62f6caa3f": { - "a3c1e6c863c3c4f3b8b293872fcf9092026993181ffdacf38c1a2582ba389460:0": 207235749153 - }, - "bea1994e4458eb6db6b0b8974b14dd6034a40ca10268f2791d043702ad8f1c53": { - "7e95e555689207da7fa5ac7fcacd36bdd6af6db0c3f975032a5684c6914eba39:0": 156250000 - }, - "bf28a3594f0984fbf16e1f06e14fb4ac565a8625e5549b5082959cf3f954e187": { - "2f693a35d64bf0e9091c33316efcdd607c2a7fc06ee37b2b41588ab84a280e5c:0": 2500000000 - }, - "bff5fce8c36f470eaaa62c6a148706665d0b47051320b2ff338ec9198f9cbb1e": { - "2cc443dfe5831031e0d5ccd82ee269d80ae8e0944b578c6acdb784205c943033:0": 199890 - }, - "c0071e6c31e287834aff2080a67a19136ec845f08b32a08e22aed79d78bd605a": { - "26171dce2366ca96fdcb0749bab05fa8b5abbf9b3a4dae1b9ae6f7f5425108b1:0": 1250000000 - }, - "c02e31e26c17a79e019a87efec883b55c0afae1819ec5b7c69d7a82045ac8e06": { - "13e88bc0b5e4d3bbc9ac545c9385133586923536a901b6858a6cb4abb76eb2d1:0": 610351, - "1c5c1433a13e1f7bd62b2f96c03839202ec3051e551724ae5c1022359b500903:0": 610351, - "2af047c4a003fb7b90ce2c8fc49bcdb3eaee0fff213b14dc1645b9fb18b8f0f6:0": 610351, - "a07e2bfb0e7b27dc3854d1a2eaebe322cd7f5c975b835fb244ce5a371e5fbd87:0": 610351, - "b66d9c820cba1ebf540d2cd93819e19b5b012f6266c3caa6c73070fc808a03a4:0": 610351, - "bcce7e1047e1b07f23f56bfc9fbfee33af6f35696ccb516bf13db41688d12d1b:0": 610351, - "ca39c397ccc1ac2d8fcb175cc47d7377c8b7f4aa6f1366d5fc027009cda92d51:0": 610351, - "d0f079bf3d4b490a76aeced6ec27790372f5f11340795a4e31d41e47bda69a13:0": 610351, - "e0e5c9bbbe3eaf651f132ecc1a70bf020be80b85541922a28e633d579cd583cb:0": 610351, - "e1cf014d84565d434119a84c746b43a3b96ca6bf1ad46b94d87e376c683b141e:0": 610351 - }, - "c37807a3949cb3f3ba63232b16950d33c32ee2858f1c8242d0a0d288981558f2": { - "9fb0d3fdca0533f86202b9885a11a483c270994c7cb2279efd5d90f2711b8d3f:0": 1250000000 - }, - "c4f5d010ae6e923c8cf9f4da8f743e737c7b5813695fbd8c15be091daa1b76ab": { - "b3081a5198913dc4292cae4d1129dc46c50019310e5a491825a80e756ce3ec91:0": 1250000000 - }, - "c51f6446365133bed19170e39b59de1ba9d8dfb33fcbad5a18c43f0c22093339": { - "e537bf41df80639d8fa4365a01782d462e6d70a1eafc0d62d33ae05665ca3c7f:0": 1300406 - }, - "c559e4cdac36213dd561602b7bef22d2d082c3e0f848d1992c60d927e20cd032": { - "6d9fca805caa0fce2e1fd0348057e2b26061f2a3e440d3f16f37fa35e2d7e197:0": 2500000000 - }, - "c5f18ef3dc496cb74e0541cc7587d6273544c26f6395552b2d88cd29c9987a8b": { - "10c52f9e9340e2e6050fee8672c62ff10f4fc1828c877a263efcb03d91029441:0": 1250000000 - }, - "c676274278b38677d2aa54dad8d35bc7e49e9cbb69383a073ea46c31158587bc": { - "7b20a4d66eadd0778533055bdee6761d3855f17cde3fe4cff83b1482ce64ca02:0": 1250000000 - }, - "c6827d83748d0b950b6ffccab58cd37533e145506be6a0f4f50146d77f1eeeb6": { - "dd0937b09ab5c56677ecc9a42f2f8907df8e6b2818497cc3aada5d90a302e413:0": 1250000000 - }, - "c823420f84ae2f4543f8563219c5feb46d4349ed3b9c4631d20081e07c995a72": { - "7da003d3eade4adc86e8d65bd82288850b119908aabcd4e18a24d1ab31d88e14:0": 1250000000 - }, - "c911e5349dba9c4c9f64c00e1c4c3b5c30a0f011c581b1878deaa94da79207c1": { - "bcdf59b2e64912ca8d8238b476d40de0073aaa0faca249b30130394376fbe4cf:0": 156250000 - }, - "c91f3304c44b0d089aae62cb12080f4b408b37ddbb1ed27e3652bd08122c9c9b": { - "86ebe3393b3bc36061d8584542d5b610ac62eab456effa5f3737dc2af00e4a0c:0": 1250000000 - }, - "c94efc017d29a6187b07b02b7e18a3a532fc8719876b1ea6e047c882157d4a19": { - "1cb59087e772b43952f6a7676b16880dc9b9f16424c31baa3d20930b6454e39d:1": 1149996475 - }, - "c98f0722b5e493fbfccd3d19cadea1c61f93106ce45e07725afeb324fe091f48": { - "d6d173d5ca721bc35f570a95d4f3cf5f1508e491d3aa904a37b5ee693148243c:0": 625000000 - }, - "cad55c2dc6d183ead28f1de0299ec8d98c1f79719ea64e987dbd2849ec97f7d9": { - "fb34ed0517114c74cd07dcbb7801f6068e57a0c8d69daa0440650f32a7e6d0d1:0": 293306 - }, - "cb47269f9edd13582a040e8346ff5bf5a385a4f9d517eae2e76ea85b5918d561": { - "34c0aa189d54f801cfe9eaa8ee86d0b9a931e6ef67e5a00d68cd060ee16787bb:0": 2500000000 - }, - "cbc75c6e489c90a3a6fd03cb6d5f2b6f55a7fc873c3c79664102cde7741a6b77": { - "de878731e9d89bc576a3e4f536a05b7cc7ddf499a61c1d2efc4362a53d6f69d7:0": 2500000000 - }, - "ccc6d464c1171e9879df6c09af2a70cfe847b318695e699aaa6fda57f90500b6": { - "d5d591d9e903d63221a818ad176237129a1f04bc3df229a4bc657163730aeb37:0": 2500000000 - }, - "ce64d2f302645c41bf4770f3f860b6fc91a5468d472101e0bb7a8d3754ef242c": { - "ba67580f9ace0b3a538e32604bc26ac5c81c1f68e46e9d4cd81cc3f9cd7019ce:0": 1250000000 - }, - "cea9965b4a1ebfada771f5905eba3b703683e81a87d1be57fa5ed6bd7990baa3": { - "83294f6f855ca49163df69e018a87ac551a415c15029e82bc5d769c907e9a73b:0": 312500000 - }, - "cef1771c665d7e4ccb21fe6cbb65f858388f69b98362924d12b8850e93d91cf4": { - "2a1b54803e6649bb49778d2b84b542c5db10351f075941c7ed6453e0c152ea39:1": 2250406 - }, - "d17eae82bb5584adafa59ec068f85b9bcf80c5ad335b5f0c388497af548af882": { - "a9980100a966dd0f2cf36f8031912332fbde49c113e9059891375c894668c198:0": 1059703 - }, - "d21bee3026462af5fd61e10e192ca0ef806fd1f62999871b0c7fe0ee3b1ccef5": { - "df39a1ff2afe96d05e40f842e7947fd4dc965fd02276a38890d17677c1210c5d:0": 2500000000 - }, - "d3c457ad1a3f79bbf6736451f1e84a17f2614e0a932893bab1a0b1672523a402": { - "7db9f22f77a74ab5402fbd3039613df49271101f24c1cf6a7ef1748f2392dda9:0": 2500000000 - }, - "d7a228ddf5cff62925cdfa1a17e70be97b39d648bbf738c44320e9787bc56cf8": { - "6ac5003d72322aec68d0e987acfa8c96d3259de8a2bbb93ec5c36ed84ec361a1:0": 2500000000 - }, - "d8a14802ef39179c9b260749386140dceb4d63717985a512ffcd9e35ccb32af9": { - "078905c318abd82e947af0d3836f38852f3463fffcba279ccb5d5dce58bfcfde:0": 2500000000 - }, - "d9fea259d81ba821f80be1ce76b17761cd4399efa4e9cd284512120f720c62ff": { - "df405bdb25adc2b339de95900097b7f9b2243ef34948b7a62180856ebdbd7c69:0": 312500000 - }, - "da1f63052cbb7b650f8004d8abbacf060204230ba28c8117e3fbcfd338bb956a": { - "58d61d3fd5b95f32e3475474f6f60f4822196944d1707a725e7e3e68d3467ef2:0": 1250000000 - }, - "da9fa41f15ff44e9b492979e9d3b2d258558a728fabc62e1580f87cfac6d7a4c": { - "604fc422eb5539fad96b6f6bca4493a3303ab5d8ec349d7668df3e7f74b3f0db:1": 199200 - }, - "ddd23cd97f878d77a48c8411c805d3dd7985e2736cab814e9bd8467dee8313cc": { - "fb650d0b87814550b9cc20e8fdfc72198bb13bbecd74ad29d9849aab039d0256:0": 1000 - }, - "de5bfdb363fe43f1c0a8f31a78317e275d0f960b16f5b16c339d5ba86a5428a0": { - "77270fdd97f95bfaf6bd3132246ec2aba1ff9a0dfb92c87791aee18cad6b0011:0": 1250000000 - }, - "dea251104c9fc7ddf392f18fb0f895fe143a19be4ffde7e66fff2bc4fda462a7": { - "9fbf42789d3d3f64765e21498102fcdd5230f83fe79b854c779dec11d12692f6:0": 1250000000 - }, - "e0a248afb1d85b3cc26cf5b6847252e70836f4a546fc2c5823f34c18c12470e4": { - "b12e7f76879b01d9f2f5ef947fc4ddf8d2a5cf0b6e8e7ce423bdb371c2501e17:0": 1249989000, - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1:0": 99999890 - }, - "e1cacfcb988452cec7694f0829620c3d4cc32a123ac6a1a394299c1faa8e012d": { - "d0af040eea426592b29f4de05650e6f9659ed23697272a5e2e45e18970a59e94:0": 1300406 - }, - "e239e37a446ed7040e1ddce9c6d3bf20a20da8c1302080c0208f5a2f5b1e6e37": { - "5b46a61246cb95387b0ae0d1a9691091330cdec508390afb51578f9164545916:0": 625000000 - }, - "e39fe9ffcda3d2c1f281f0744397ff53b362d2006d78b9cc79df54bfd551104e": { - "538f17b402fb20163d67d4ea088875bc89bd4825dbfe18696047e5f793ab4f22:0": 1250000000 - }, - "e3a08400edec622f0cad5f6186a31c960ef79f7f492eed9a7825df9ac701f9fb": { - "98f23473f46137c257a9bccf55cfe9e8efb95070a8a3f3e6af02b96f0dac639f:0": 1250000000 - }, - "e3ca5f4108fb6b06a7f2054a3787b4d3fb934fba5638c9574b44c3e66744d0d5": { - "c973d0ca494fc62c06c53bb0638888c0c776552deea8090df707f464346d7780:0": 1250000000 - }, - "e413548e90e35f3d3a95542394e0832bc5a950124c36a9f19f3e50bd1faf0223": { - "6e812ee5a44c2d4428d52e65854cf63f7c2cf02e4b7879132add5a86479b9267:1": 879703 - }, - "e448bb93e28d563399677b0a6749d871a0464337e6ad1f695351b4173d9b96a1": { - "d3e22b16fe54e43b38579578d443bcf886f13812eb8d791e36b37e9e92e6e03b:0": 1250000000 - }, - "e4c1add4db336375e804a26a255fbae94337e441894936f3b41743bc7d816ac1": { - "da4c26adf24970b88b3e7cb5d9af70479a065172308c96aa8e721b09018780d2:0": 1250000000 - }, - "e5f543ee07a4787493a94df6b2a17c7e0df8523326e572e7826c0594d32f45d4": { - "62cb262f03d307f0aecccb38d39fb6989913abd6753f7a9652876840d7822de6:1": 1149996475 - }, - "e878f2beff5df334c94ec59e5343a97ae2ee9d1a54b6e85de848df7228e7b67c": { - "c626a9ebc81aa9b6b75bab9563a03c97846e4eb975a98e4c54ac1513d0f87952:0": 78125000 - }, - "e88276629945adace0a8e78a95a5be4b448b1c8162dc734340e11dc4b0249574": { - "b446262c5bdcfcdc11f954d6eb102dd68e589155855cbbcc3e4e2659e8715977:0": 78125000 - }, - "e9395fc647b8e3629c270011497fba6fca8e32d6827375b7d216a450dabbd10e": { - "194156b5c46845aa63322f50a1320f41b9b0368f4310e65472a273e1a54c9ac0:0": 625000000 - }, - "e97be5062af9c9aeb8c66d9826cd115b82f652a17a25a4e71aee1a6cbd31add4": { - "6c382045994cc6a3ac5c1079d815ca306c95100a1ab3422ccf8464db49cb9ce4:0": 1250000000 - }, - "ec27e81c4ea186494dc82eea89a534c51b29fe79baed16d2e592f12d52ca6879": { - "fac0e4c4e2034b9935f001cf40f773f8bd1cd020d536c8285c96b16fa5bec080:0": 625000000 - }, - "eca898e2d007e4d1be9faa03b7c1aadffaaa0146cc5bcd116ece5751d5f210ed": { - "1d8f56e554f12225da4fdbf0fe0f9b73a9649d183d14a59b7b7b6f21941430d8:0": 625000000 - }, - "ed0422141d57bd33cac1b65ebc6d65ea4a979a111adb0861d11c26f9a73a948e": { - "00fda0d8fdc53b1f95410bafca884c5dff8ac1e5c168ace5f09a32d872b7413a:0": 1250000000 - }, - "ed567d13e2e79776d1c530462ba73a978ae0d09536667b63e1418078d9b9b899": { - "e6f6357517b4ad5c6c5eadd6e815ea9c9a6396eb17e592767d8ac63139e43171:0": 1250000000 - }, - "ef16a018fa1ff2b84ded232712da3ff82fe4d8de1c0603a5682b26ab2baba162": { - "5873c9a5aa8e854f985a0ff86178e734eb100f17f4cdaf18bc0305ff398af419:0": 859406 - }, - "ef680799d3d76c49c026457f349016b7b3dc0b015a0e2051e72e2cf4b0faa3bd": { - "8e8c64dbd7432177be9ee1b7c73169abb39e902f98ab8f0eb5fd178b8f3922cd:0": 625000000 - }, - "efe8565fa75b3744e91e6290d520f1188d37146f95be42d53c114c871bfc6225": { - "6bbfaa6c096b36c2a91c995e370e4721ece1b07a4c5cfdcc6e728f074f317542:0": 625000000 - }, - "f0225897a573517a57f4a700ba741b147d146f33738e63fe261c298cc2020824": { - "6f16141099318c869291db432b631620b5f182744e8f50a279ff5cb62f063485:0": 1250000000 - }, - "f0d4fd158c63fd833a0f725c7d6461250b5e68c7911397eff2b372388935ed9e": { - "ddd6e44aa204ad1e4fca370602b67bd07395a04b5330b37ed5bbed3648ca823b:0": 1250000000 - }, - "f255ffe1f024ef3fefefe58109e31fc1a952efb9eea97c1280b76b9c12f90597": { - "02dfb7815efabe1183ce34908d4a6e6a3f25495ad5f57120640843d418479e7e:1": 68897281201, - "065cac0bb30c763b66c25e890be537589f24cb90219bf30da96a5d37e051e052:2": 206844912911, - "0fb9da0e14c71d8b85bf00d23fa3b599972ea48f94e567e0f9733ed7357ba686:1": 68897348189, - "1607656494c74bcfa5ae3a9ef9136cb5f6911b662b4d46ab4ec953730fa8c2a3:1": 20000, - "1c3aaf7032b56afff6eaac3043aba9d7044fa109d6969306e8a1ff9bf715e5d3:0": 320000, - "1e4d59e4cc5b7cd10be6624dd46bcf90cce2fd6320fbd496a71117cf82233df4:1": 68897331442, - "335d73d9f9295b283a07187652a587830eb1eb2ee2829ef566dde8445946e474:1": 68897247707, - "3ed10a61c4b0e92b1195949098c878aa313487cbfdfd4bb921263136cebabc8d:0": 1000, - "4b04207325babc3e30db43a780637e65263082ac655519784c9671209953f7d7:2": 206844988111, - "565673db2879b2a23d8643e394bb609fb41ec7ed9510e3a0664654c529388177:1": 67680, - "5a5a803fa15d65a9cbfa7f72622af5d43e1027cd15ce33ded0fb7dd84d5aa0fa:2": 206845062111, - "5c6d7a19cfb35a98949a25c5fede1dd798a7e1509e0067d000319126c34f8c15:1": 40624807300, - "697895649863c50517092f62b47284ab00dd812a100a2ca6ac9469b5e4f6da2b:1": 50649683200, - "88a56990a69eb1a7c11595b7a88097d799fb6070bb2f77ea34e7f2fe8d54d867:2": 206845458111, - "8cebb4ea3a63829a2787c3a473f14b4f73ced07552f73ff8c2b1152169042550:2": 179832210007, - "8ec1f1de03d4af8a3a22bc479fec5b766f4499d955def32d8aa58b8873ecf9fe:0": 2500000000, - "a001edc1d43b5b41adc5a4c5ce9b6edd9dcad9fa3e50cca287d256a23cca9d4e:2": 206845236111, - "a1aee934c5dda700d15934667507f90db1ce36d7dd3068165b54159df320cd02:2": 206845310111, - "a1c254273575d854cc080b9e45d7cf775974c5d09dcfff7b5a42050221216245:1": 10010, - "a9980100a966dd0f2cf36f8031912332fbde49c113e9059891375c894668c198:1": 20000, - "aa407bf21c31fa3ebe8bf074767bfec17df01644da94b3b709c3d3551f3210ca:2": 206845384111, - "b28b20fb9089f2d021d812dc34ce461fdcd800137434edaa09160a395c182c8c:1": 68897297948, - "b6e993fb2b0584bb35b8eefcfbe6ba7c9b9b7875e69389063c96f78dd1fca9e1:1": 50649701700, - "bdaa76d37810e4d4ad1d2e7ac6e198ad96c48683e552e874901a7af3d8ae4e50:1": 68897264454, - "c375a00e0ab9e41d86d0b2e2c794da72160c7437b79e0f4a51ff70814e760c01:1": 68897381683, - "d4495edb0266dccdc77a8f1f6899f0921f56ba14e80163578d122dfd9f0251c4:1": 68897398430, - "d6c4c80037ef1102f569193b117c4e2844136656de105f8d16a4cd05556d1d94:1": 466700, - "de761fd6c3c65de80fd53aadfaac5558eb5fc4c1f2f0719da2e69ffc81e1e671:1": 20000, - "e0c901fef09db972399e8d4fd746c881bbe9a600e9605a27c5f0de8c36e8da9e:1": 12300, - "e216f36276646b01f5e5af7bced7765a18870e8b5a7b0827938cdac8b5d97144:1": 68897314695, - "e2d2f23a7291b8cdbbffffc516e433a30c537f3c4f042f9a5aca924d57cc0ea3:1": 68845331866, - "f1c2d97b87ec7ed1b58e8b2b70dd3263e958e6b02a45477ef9b2c5f98f53df25:1": 68897364936, - "f44c4c0db09e3cf83836a0df6b07e99ef52f8d044332cd34f95e57bf6a0e7f79:0": 12300, - "f44e4d36066cb632ad90009562e2bb1d6b8e756a1a3ad7ce3640decf5384fed3:0": 10010, - "f54b7b6d83112b7f7e052e296110638d22e142fa55ec651afd0f4122918b21fc:1": 20000, - "fcbee4c653cb3695c31817b0fb8ca1287fbb67fa8ad72801b520eae222878536:2": 179832274344 - }, - "f2bfbc6c8825a54a726815b9a7050b018ea481fd883f13b315fbc3728b97269e": { - "ae8e62c0ec616466782a4f861d108ad06e593af6c8a4fe3d2c9ca1386fd559bd:0": 2500000000 - }, - "f2d6a6691a2257691d8a401d14e58d90f4cfd6e5d36af0818819b64df74ff082": { - "a8f03bf9ac0311e6dba5d79fb62a881fa003b019312e49881b327b00578e1cd5:0": 1250000000 - }, - "f558e5f0cc4150d66778f6af8e369364f21627b7ea69757fec0851716ceb1564": { - "f1634626cdc61a8f9db3698f98838655f88a0b5110a9302f4921566a056e101e:0": 156250000 - }, - "f75d353c1d31d887b1627f8d12854c1b2f910d99bfc2d19f89c08cc823e3e789": { - "7a0f7f676756ed94adb07d623865fa5e2b702de76103bd50ffe26a7d8366d707:0": 625000000 - }, - "f8016b878e537c9bd79c941e3c172839c431b9a9868539080f5cd6da7420e129": { - "e123277358af81e7ede30e21956187597413a63a4e31c7931dc7dd80c153079a:0": 1250000000 - }, - "f9645263efd7f8f913a8407ebf71bf70215b4bd457a6f9044f4f89624160c984": { - "7a721c4246058cfa85feb2aa781e03ff7c6a0a54b796dbada167543c42080dce:0": 2500000000 - }, - "faf6d0c76ec8a3827ba133d2d99e3e31e93e421686b7772725db09869208e584": { - "a1c254273575d854cc080b9e45d7cf775974c5d09dcfff7b5a42050221216245:0": 414393 - }, - "fc103c50b43fcb03e188e3d5f5881e33711e0a37bd7d2cd70dbb027ee8b338b8": { - "2c6d3a41eb0f5589e2586138b2bee1cf453144fe48dc73c2a3b0f4d0a3bfd596:0": 1250000000 - }, - "fc2716e67839c8453176baa749059525f96b351d62051ef046c748cbc927dc48": { - "18b7f4716ac3a5d9acff42b8b0d3e95dcb17893f7a71dcaa135a3d085ef532e3:0": 156250000 - }, - "fd53fea00d52f5457a9a521b20a050191f784bdaec493f27cf5c104e6387b5db": { - "718b9758764cde8e5d0dc0824917e795cbd64bdac2687033e379e556933ce1b9:0": 1250000000 - }, - "fdba6c663303d164e7ac5205846253c6747c2430bf1b900392ff8f13fa6cc9be": { - "41a7014c7cf88df492204f9c01d3f1f6da27689514c454648efeef38b7a70716:1": 454306 - }, - "fe31042612d9c30f17ab350c981307ae8bbab001302e9792390bb58849d0078b": { - "8015500aaf8017f883467cb6d8ab7d1ac0514993af4a99fd303ef5d4c6adda83:0": 1250000000 - }, - "ff8e31c56f2183d00a93a1fdc790f3eed475bd109ce1fdd7bec3f7cefe6c45a3": { - "211e0cb1f99e931a517437fe2b1ea81e97332b8abf066a99704da17bcbeabb43:0": 1250000000 - } - }, - "qt-console-history": [ - "for txid in wallet.db.transactions.keys():", - " print(txid)", - " wallet.db.remove_transaction(txid)", - "txids=wallet.db.transactions.keys()[:]", - "txids=wallet.db.transactions.keys()", - "for txid in txids:", - " print(txid)", - " wallet.db.remove_transaction(txid)", - "txids=wallet.db.transactions.keys()[:]", - "txids=wallet.db.transactions.keys()", - "txids", - "txids[0]", - "list(txids)", - "txids=list(wallet.db.transactions.keys())", - "txids", - "for txid in txids:", - " print(txid)", - " wallet.db.remove_transaction(txid)" - ], - "received_mpp_htlcs": {}, - "seed_version": 71, - "spent_outpoints": { - "00fda0d8fdc53b1f95410bafca884c5dff8ac1e5c168ace5f09a32d872b7413a": {}, - "0254d8a8a3e3a8389927332b075fb8bd190f6cb789f5bb3941dc1ce6581e476d": {}, - "02b85518c4b0d2c17d43e88cd62552b0ab8da2ce0605ea3a8b3cd70b0395c6b6": {}, - "02dfb7815efabe1183ce34908d4a6e6a3f25495ad5f57120640843d418479e7e": {}, - "030a1ef891f4a7179a5d050fb5160c361ffe7a5ba72211e4181de177224ea25c": {}, - "065cac0bb30c763b66c25e890be537589f24cb90219bf30da96a5d37e051e052": {}, - "078905c318abd82e947af0d3836f38852f3463fffcba279ccb5d5dce58bfcfde": {}, - "0a9743ce1cd227e12b247204349c7e5f801059806543e91fcbd1c6c8e70159aa": {}, - "0a9ade1ed72b0b394bbc3ac826146f2650122b01ff9db251b2ceef69ac4e3177": {}, - "0b2d3c47e2f34a34baf07ed83308aab05101b3afd3a0494167e6db0c46bb7bf8": {}, - "0b34c01afa7c05d19c442b8a976679a3ba3c5192e2ea155d83ada16630dba360": {}, - "0ef3b8b6a2c3d28126a7c79aa95b5bd1f091b7eb8c14c20fd5ee89c409462c94": {}, - "0fb9da0e14c71d8b85bf00d23fa3b599972ea48f94e567e0f9733ed7357ba686": {}, - "1085a800d8e548c347b18fd80a408c86a76f35a31f26ec742be918ea4dbe7927": {}, - "10c52f9e9340e2e6050fee8672c62ff10f4fc1828c877a263efcb03d91029441": {}, - "129a01db49fce205b86d4774ac012c1bdf4c5c402925a4739dd9797e424ae58b": {}, - "13a5f740f9be5aa8b184cf5f57ea7993586bfb793dc421850bfc032ff6ad31ba": {}, - "13c4b8b888b4c7f68076855c11d51eace7ee8d6c106142b493cf47a65a8f5be1": {}, - "13e88bc0b5e4d3bbc9ac545c9385133586923536a901b6858a6cb4abb76eb2d1": {}, - "14989ea4da2283527831cc304ff559b776a79a2a115c03ef976034f18b44c105": {}, - "1607656494c74bcfa5ae3a9ef9136cb5f6911b662b4d46ab4ec953730fa8c2a3": {}, - "176c67ae60460acbb2ccd663b0c6983eab6766095870ca6e6e2ffb77b310316e": {}, - "18b7f4716ac3a5d9acff42b8b0d3e95dcb17893f7a71dcaa135a3d085ef532e3": {}, - "190baa4d3d24145ae21e16b9786bb15452ade631265b79c9a30663b8dea66c75": {}, - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525": { - "0": "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1" - }, - "194156b5c46845aa63322f50a1320f41b9b0368f4310e65472a273e1a54c9ac0": {}, - "1b080dccf6a653d3177683243719ebe9eefd62fa4c65ecb040b9b315bef7d54c": {}, - "1b3dcbd7bf37a23b56ee9db1182dff5e4cb238bc112b52cc3059f037c44bcac8": {}, - "1c3aaf7032b56afff6eaac3043aba9d7044fa109d6969306e8a1ff9bf715e5d3": {}, - "1c5c1433a13e1f7bd62b2f96c03839202ec3051e551724ae5c1022359b500903": {}, - "1c6852c5b99bbe89e5cde3db9074dc2a84a3f99e85c396a05ae704058d3f90ed": {}, - "1caa837b9b99f86cc5d0802d2da24bc4482138e10a280c92e85e8b80130dfdaf": {}, - "1cb59087e772b43952f6a7676b16880dc9b9f16424c31baa3d20930b6454e39d": {}, - "1d30124dd0acb5c11a2cb8e607c50b5f402a7be178cafb264747fb0dab37c8cd": {}, - "1d8f56e554f12225da4fdbf0fe0f9b73a9649d183d14a59b7b7b6f21941430d8": {}, - "1e4d59e4cc5b7cd10be6624dd46bcf90cce2fd6320fbd496a71117cf82233df4": {}, - "20d7a08375644ccc6cacacf329b681104c774095b99d15dff22b4ad78bb2f121": {}, - "211e0cb1f99e931a517437fe2b1ea81e97332b8abf066a99704da17bcbeabb43": {}, - "26171dce2366ca96fdcb0749bab05fa8b5abbf9b3a4dae1b9ae6f7f5425108b1": {}, - "267eeb8e65681ffaeafe0a04616b270750f1f0a83e5d51f82974ad0c98e2c5a6": {}, - "282778a46251bd4de17361ee948d6ca274ee0668b190398ca85fd939adb59d55": {}, - "28837747eb6a849543f5f03063f1436fc44f64f028448022ec0c382ac5bb7c31": {}, - "2a1b54803e6649bb49778d2b84b542c5db10351f075941c7ed6453e0c152ea39": {}, - "2a35cd9588ee727bc532bcd185d8ef78d5d54ec168cdb2d49516e8042ddf05fb": {}, - "2ab0948cddb04221e6ab88d2d9ba5ac11db5e5d1e0ef89ea8cc69b19a17dc6c2": {}, - "2af047c4a003fb7b90ce2c8fc49bcdb3eaee0fff213b14dc1645b9fb18b8f0f6": {}, - "2c659529455fdacab80781dab351553428e85f2a491866b0a8274dbedaff4f4c": {}, - "2c6d3a41eb0f5589e2586138b2bee1cf453144fe48dc73c2a3b0f4d0a3bfd596": {}, - "2c7b1c375b27d7c61eebbb1d9feaeefe0133af1255ec705e4053a7ec66be732a": {}, - "2c8737fddbcf0d130dcfb72dc0fa000a7dc70dad88dee6aed2acac1320e8fd06": {}, - "2cc443dfe5831031e0d5ccd82ee269d80ae8e0944b578c6acdb784205c943033": {}, - "2f693a35d64bf0e9091c33316efcdd607c2a7fc06ee37b2b41588ab84a280e5c": {}, - "316b0ac8e6516b59633a46dd5f4fa19da180f799f871a57085f6a7e99bb561e7": {}, - "32656b80136626aeef1f136f9e9d2a2a480df0de7b69a195441ab611008abaa3": {}, - "335d73d9f9295b283a07187652a587830eb1eb2ee2829ef566dde8445946e474": {}, - "34a279c4278bbf42736f5345d5882058abb0a4cd7ba8f9ec3c094e9dc9ec008b": {}, - "34c0aa189d54f801cfe9eaa8ee86d0b9a931e6ef67e5a00d68cd060ee16787bb": {}, - "34c332100722016bd4c4f7ea30b9e062ad7934a79cdebecc6cd7eeeb06d2a0ba": {}, - "360c08390022019fdd20d6c27415592cea239e8617bd97e6ff4209a317ede43a": {}, - "366af56dbe15c77b6461b49597e6c6cd556721ee9f40aed811264ac551ea9850": {}, - "36ac1d694a8810ff6e930a2f7751191e72414ea832e8fa66ad4d087f09064cd8": {}, - "37b933d6b0d52746412a4bc06d9e7bf6b15cd42cafe8938f6f686e416547322f": {}, - "37c7f8e63152bad6ff3b704e4a60985cb25d877606c7c0514e7ada480f99ef1c": {}, - "380302d4d042b0d3b6b0896e98b1dbccabedc24734dada0ff7353732a61c15f4": {}, - "399231ca4ba596e68131aeb0b7c5e721c7b90a4e7c27e4848487fb0453c27d2e": {}, - "3d2f9eb3a839a98bb53c6df9b9df3af9420f487955329009ebe6d5ce35c0d7de": {}, - "3e3c31ad2bb0d69050a4880982e1884ca56be999b14b53d14e4c60524dc224de": {}, - "3e90e0c6d764847423f69b9b18156ec213d7e997302ed984c50f82e2ce15d543": {}, - "3ed10a61c4b0e92b1195949098c878aa313487cbfdfd4bb921263136cebabc8d": {}, - "3ffbe82458b16661b67f0dad94cfaf47c5c1b429906052eaeca6c09b96cc48d0": {}, - "405f8a3b27b271b5d03581234028e814fe2a040f5c963b67e92b3946d0968faf": {}, - "407d783397625ee8511d287dc7196ae6bfb9ec7e86ee8f37d8ac35c27704737e": {}, - "411e92637bb0ec8c71ec0bc892cdc5b897d26dbd03ba437ffb57db58ff320894": {}, - "41a7014c7cf88df492204f9c01d3f1f6da27689514c454648efeef38b7a70716": {}, - "41df75627f82b7db83eff2f542ad814e0e013bf44d4f67e66414d3a6a0ecb3b4": {}, - "42a1d1b8b6fbfa6b9f1158c2663e00c023a278d9c587901a40ca77fb5835e471": {}, - "44461d09d9fdd865944d6557405134f19cfd817bc293270223c7a98c862d03a5": {}, - "467dd15b59be876ef199d91d606ba0ed1adec119ced785509457da0a53450842": {}, - "4b04207325babc3e30db43a780637e65263082ac655519784c9671209953f7d7": {}, - "4ecab20ad3b5b41255fb7bd8664aff4a79c66081201d13e0eb138038b4f016df": {}, - "506e0e953ba217afd18465980c16438947d86ebd74959c689b3b53d1dc2fe2d5": {}, - "516145bf354893ca80ccfaecf05620a4d99698a515d135cfbfc4e12f80f617fa": {}, - "52803985df4cb02bd28ddbf089671e5ae130b366bc348fcb4db0002c3d2a0756": {}, - "538f17b402fb20163d67d4ea088875bc89bd4825dbfe18696047e5f793ab4f22": {}, - "53e7c7da8b5db5e20dede3ba81cc308960d554fc1c6322b74a50756f92a55692": {}, - "5584a54ac4f0d5b1456e336c6e8359388f8a378842b535f7acc19441c42dddd8": {}, - "561baf14fb75bf05ff0222d5294d5332f500a9c9c4f5948eb2903120ca606aaf": {}, - "565673db2879b2a23d8643e394bb609fb41ec7ed9510e3a0664654c529388177": {}, - "581c27a00de5773b917c838e7060519b4502a40522e3fb846a8ded340d1f72e8": {}, - "5873c9a5aa8e854f985a0ff86178e734eb100f17f4cdaf18bc0305ff398af419": {}, - "588d3d3972eb0ef218e66d150888c10550320cc4fe1d2be41446a0fc9d7b9822": {}, - "58b5515926cf07136d98f2b0261134eb76586fee495f9848387458b14d74c9bc": {}, - "58d61d3fd5b95f32e3475474f6f60f4822196944d1707a725e7e3e68d3467ef2": {}, - "5913dcfb1843ffcc113fdea4df6be0d5fe6ec1546f54c7387ae85d22e1062bd5": {}, - "59302675cbb9ff422c5c6ac365a1a4f2798e9183c4f027463573f0cf1ff9fb26": {}, - "593bb86df1956612d130a5aff27372e6d4c2c5d56887a4ffa62a06a1d729df37": {}, - "59ae2371837bd090bed2ea86faa9ca77eccc9d74dbed701f9398434986a7110b": {}, - "5a5a803fa15d65a9cbfa7f72622af5d43e1027cd15ce33ded0fb7dd84d5aa0fa": {}, - "5b46a61246cb95387b0ae0d1a9691091330cdec508390afb51578f9164545916": {}, - "5b67bf78a3b367a4b05806efa537dc1ea82ae8bec788954b5e178c21ed53b385": {}, - "5bbc95c1594a4cb32987c87f2fffe234cba41325ec79e8c31a87dc3f1b67dbc6": {}, - "5c6d7a19cfb35a98949a25c5fede1dd798a7e1509e0067d000319126c34f8c15": {}, - "5d9d23e6caf4d2bdeac42ccb33d1c867f5b356cada6f4a301dba292354909a62": {}, - "5dfdb2b1b798b58b3e7813ea3e4b7bd7809f6c03ecd84d7de0ed5980a8c6edf3": {}, - "5e932af4d0bcc51558f5c3257df735cfc08c223112322faf2d06504749ec0cf5": {}, - "604fc422eb5539fad96b6f6bca4493a3303ab5d8ec349d7668df3e7f74b3f0db": {}, - "616f81a3dac9b300c1c95ef4353c30425b29f73cf6781b89263890da79278cd1": {}, - "61bda73234fc36ba88dbf8a5281e419f8a2de1d41c91c2c61c0f03bb48d15b34": {}, - "62581f3d27c05dc4573a7cd31afee05b6ccd2209953cd924a171cb1021cec290": {}, - "62cb262f03d307f0aecccb38d39fb6989913abd6753f7a9652876840d7822de6": {}, - "642e892416a039224ad50e987bfbfecefee57103dd39c6833e2992cebf8a2e8d": {}, - "6444792f99c990677950a6e8c27a18eb4aa1eb946763887cd8c67d7495cbd1dc": {}, - "64f40121401bcfa201bf59dc9c8d23e2fe89da362685bf64694eb98f6f4fc991": {}, - "6672bea8ae29f1b2fce4fcedf95ad863f69291d5ff2d8e2c3a58d9d1370d5ebe": {}, - "683425171b094d3e222a7eb6327e2276e8a66f02314d57cd3c50271eaef8d3f9": {}, - "68a99483af54769940b80095e83d95d607f9c32c2d03c0b89a86cd4143d65eb9": {}, - "68b9c5bddb83e4f54192b67a978f6bea1e305ec558c1042623d6bcc54188e135": {}, - "697895649863c50517092f62b47284ab00dd812a100a2ca6ac9469b5e4f6da2b": {}, - "6a52b7fb5be6115a7d4283c8a3b4769a17de45e61eb4925e99e8af930c4c4ae5": {}, - "6ac5003d72322aec68d0e987acfa8c96d3259de8a2bbb93ec5c36ed84ec361a1": {}, - "6bbfaa6c096b36c2a91c995e370e4721ece1b07a4c5cfdcc6e728f074f317542": {}, - "6c382045994cc6a3ac5c1079d815ca306c95100a1ab3422ccf8464db49cb9ce4": {}, - "6d9fca805caa0fce2e1fd0348057e2b26061f2a3e440d3f16f37fa35e2d7e197": {}, - "6e2570c89ccf738ee7f67c25abc7feb2b85e1501fca49e05fbe4439ff8ab7af5": {}, - "6e812ee5a44c2d4428d52e65854cf63f7c2cf02e4b7879132add5a86479b9267": {}, - "6ee246406a5773dbafd53b39b6b787a46a631917d83dc94338aa4ee0d4796d36": {}, - "6f16141099318c869291db432b631620b5f182744e8f50a279ff5cb62f063485": {}, - "6f61e5d05d994e581c48770b5a9800230fa3bd4aac0b3823cb9f3097b36e5297": {}, - "710d8af6e637597156655c8f4f77619396361a23c449a45c11e5073bbb37155b": {}, - "718b9758764cde8e5d0dc0824917e795cbd64bdac2687033e379e556933ce1b9": {}, - "727450a0d92fc99255fbfb5de85245b2387314f347f12762ca7e3aafda0ab5c4": {}, - "7313ffb6366bab4cbe68f014f11e3608c308a44314219bf157a5c3167aecf254": {}, - "747d4ce0c1365c99205ed6475451083149bd2764b28543775bae77df6e892649": {}, - "750b38d81dbbb4eda455c2fdd27bb746c9eadd390034fc184797aceed6b1d20f": {}, - "757ddbf9ff3cf065bb602a13356ffed5374586ae897baef6a2b72386e8b8aaa1": {}, - "7698e42487b56fac8ef417a78e02af91ec0fceb77f5f45a5e8060982bfe143ae": {}, - "77270fdd97f95bfaf6bd3132246ec2aba1ff9a0dfb92c87791aee18cad6b0011": {}, - "79e66107ee8ad8a0c93b34ca80bd83c33efdc1eb25dd1ff902853bf77ca39cf0": {}, - "7a0f7f676756ed94adb07d623865fa5e2b702de76103bd50ffe26a7d8366d707": {}, - "7a721c4246058cfa85feb2aa781e03ff7c6a0a54b796dbada167543c42080dce": {}, - "7b20a4d66eadd0778533055bdee6761d3855f17cde3fe4cff83b1482ce64ca02": {}, - "7b5ee04a3769bc5fa3230eb0fb1f7aff9b0ff9fa70815b77d0cc828bd8e51303": {}, - "7da003d3eade4adc86e8d65bd82288850b119908aabcd4e18a24d1ab31d88e14": {}, - "7db9f22f77a74ab5402fbd3039613df49271101f24c1cf6a7ef1748f2392dda9": {}, - "7e95e555689207da7fa5ac7fcacd36bdd6af6db0c3f975032a5684c6914eba39": {}, - "8015500aaf8017f883467cb6d8ab7d1ac0514993af4a99fd303ef5d4c6adda83": {}, - "803341da80f8d6939a6aa1541d1ffcd27445c823178fc45cf1295b9e3a167be4": {}, - "8081a8fcecbe22cfda263a28e331c5eadd9f2924f554afade030a2c57ca18a1b": {}, - "83294f6f855ca49163df69e018a87ac551a415c15029e82bc5d769c907e9a73b": {}, - "83d64baa165f011c9c6902880c2fefada3df75c1271a51da205e383cc6d58327": {}, - "83ff9433dece51bf3a37f60c81e196fac96cef88c587a2f97fb44be490482536": {}, - "856f4b1a6c2b8055e6b48686129878c68f83e0444a00430cb319fd7186e9c1c7": {}, - "86ebe3393b3bc36061d8584542d5b610ac62eab456effa5f3737dc2af00e4a0c": {}, - "870344155014b8e1f8b649235f228c8b4833aef7265b03ef1c9d91e5fc9bc593": {}, - "88a56990a69eb1a7c11595b7a88097d799fb6070bb2f77ea34e7f2fe8d54d867": {}, - "8a52e590d4dcf4a04bb46c4064adebed67556bfcf621f7775fd85d4a9c9c8132": {}, - "8b0704efe92f0faf3d586e9eab68424adfdaaa66aeccf8207f40b50562e2e410": {}, - "8c7dd2039eea630dc040c74656ffac605bb44c0e7e712f0c82560719353152cf": {}, - "8cebb4ea3a63829a2787c3a473f14b4f73ced07552f73ff8c2b1152169042550": {}, - "8cebd66ae223c28c1db0fbd9638668ae9a28c757b164c97e93a22c9b2ef9fb8c": {}, - "8d92004b3bb7730c3fccb14ffb900d5c8534e51b104341fba78152c5ee663baf": {}, - "8e8c64dbd7432177be9ee1b7c73169abb39e902f98ab8f0eb5fd178b8f3922cd": {}, - "8ec1f1de03d4af8a3a22bc479fec5b766f4499d955def32d8aa58b8873ecf9fe": {}, - "93b5460b7ea21bc89819b080253996a1319ef70d635b1b62fc50151d4255ec94": {}, - "9452d910edfa65523f9e7c568573dc0389e675cfaf72460545721972d5ae0255": {}, - "9599a895c1329dacef4d492690616abd9237652984859080b239326e2d11fd84": {}, - "981bf221a2b204dd365a198ab358d04dd04dd7315e3b8777ac5d1d13ca51624c": {}, - "985d8a22e4c99763b38aa6be06c74010583083d8dedaf2524105e2f84cc8580f": {}, - "98f23473f46137c257a9bccf55cfe9e8efb95070a8a3f3e6af02b96f0dac639f": {}, - "9c83959467c36c041bcfb223fecc9d213cf431ccb6931407918759f5556690f5": {}, - "9d8706fba0785e3d92ee79dd1ea7a49478ac595a431541b05da53acd73a9c806": {}, - "9db29a1b50318b2e9d68eddd92b4dd5f5eb3439a34ad360d97ea1ba7bbdf6f55": {}, - "9f074d00dd12453701c5403934614ec84aef4fbc191b5728386f052707ca407e": {}, - "9fa4b639e4603f0de8f5255acfcf217837b55351342a012b11f7f44c35a9fde1": {}, - "9fb0d3fdca0533f86202b9885a11a483c270994c7cb2279efd5d90f2711b8d3f": {}, - "9fbf42789d3d3f64765e21498102fcdd5230f83fe79b854c779dec11d12692f6": {}, - "a001edc1d43b5b41adc5a4c5ce9b6edd9dcad9fa3e50cca287d256a23cca9d4e": {}, - "a07e2bfb0e7b27dc3854d1a2eaebe322cd7f5c975b835fb244ce5a371e5fbd87": {}, - "a1278eda0b98ba5c2073f32665bbe6544d183c37f27e800762bca7e6af9366eb": {}, - "a1aee934c5dda700d15934667507f90db1ce36d7dd3068165b54159df320cd02": {}, - "a1c254273575d854cc080b9e45d7cf775974c5d09dcfff7b5a42050221216245": {}, - "a262585a6212f09eeda8aecd6005cd6c0518a683fd35e9cb960e64a59f47ecac": {}, - "a38401662d29ba7ee2ef9857996246d73401879b2c458b18e862a035ab6d4d50": {}, - "a3c1e6c863c3c4f3b8b293872fcf9092026993181ffdacf38c1a2582ba389460": {}, - "a3dd79d1150676942c0bdc5a617d0eb258cff05459c4a54f17f1bcdd9c73f238": { - "0": "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525" - }, - "a456463094a89018dd2252696ddf6c6a5a47797a5b3879418119d6d725ab3dca": {}, - "a52ba8af4045b4e27e3b2e194e4f8f814dddf366a556dd71fa241f948c424e9a": {}, - "a89cb324d4efb56f9344fe323eba15835188e1eb6b6d52ae69cf2e3664fa3fc3": {}, - "a8b6b761eaf7d647ea08b3f58fdd382812bbefc5f96acf114d1be3be2ab1d94f": {}, - "a8f03bf9ac0311e6dba5d79fb62a881fa003b019312e49881b327b00578e1cd5": {}, - "a9980100a966dd0f2cf36f8031912332fbde49c113e9059891375c894668c198": {}, - "aa407bf21c31fa3ebe8bf074767bfec17df01644da94b3b709c3d3551f3210ca": {}, - "ab1012eb232c0070b566b6620a8b839e8fa78831cf8216337c1db71e35b89953": {}, - "ab74079fca87f186df5676c62de63d8b21e98746c8c8888f108310bc7849e70e": {}, - "ad3f3d67b74bb4b9b4457f1ccb99ea3dad02ad24c0f4d2383ff1fd12f7c0a684": {}, - "ae8e62c0ec616466782a4f861d108ad06e593af6c8a4fe3d2c9ca1386fd559bd": {}, - "af9e9fbb8e65d724bbf483ed4b7577ff183b0e8ddba91ca40456d43b3135f81e": {}, - "b00e9f65ca32f0ab7a05666f21a78b461623c43d1297a731f09a807f2c9dc497": {}, - "b020ccbf483abab08d6553b7fec974ce9f8ab43654863757e3ec7694c399f86c": {}, - "b0d5833bc0b04e9d59e319e2f5cc8ddf382d70208848ac6a0302c675a1ddb74d": {}, - "b12e7f76879b01d9f2f5ef947fc4ddf8d2a5cf0b6e8e7ce423bdb371c2501e17": {}, - "b1a6dcac01ba56c1b6fe3c925946f8e2e1901a413b53fcdd720eb61572d2fb78": {}, - "b209cef8863e4401c96eb527445ddefb18d9e515cb6551936f6e55c331d53809": {}, - "b20a692e1d25335e608a57eca240e4a212d096d0233a98ee2ab8c6ba49b3f2e3": {}, - "b244e6eb9eda76eb54274d9d8758268c6f6db03257f738c7bf6d05f4fbf2208e": {}, - "b28b20fb9089f2d021d812dc34ce461fdcd800137434edaa09160a395c182c8c": {}, - "b3081a5198913dc4292cae4d1129dc46c50019310e5a491825a80e756ce3ec91": {}, - "b3e3d7cba32180e7a72a2e66126d6b2bee9e386788ee099c5f032efaa8772fe3": {}, - "b3f2c1e7056cd993e43da5b00e634d7be6bd0bb0e9dbe9a0ed09e0ece868b879": {}, - "b446262c5bdcfcdc11f954d6eb102dd68e589155855cbbcc3e4e2659e8715977": {}, - "b47c4bebb152c45418b6df10280ff9fd7a4be4fc5f9c58c6a3d92d1652a039dc": {}, - "b481055063714d06f1a2574c4b554849887731181cb71d8964ea54cbd389a9e5": {}, - "b66d9c820cba1ebf540d2cd93819e19b5b012f6266c3caa6c73070fc808a03a4": {}, - "b6e993fb2b0584bb35b8eefcfbe6ba7c9b9b7875e69389063c96f78dd1fca9e1": {}, - "b8c43897c5e7b73a57ef49e244ea1b4931492ccafe1d3996065935ea6b6fd68d": {}, - "ba35041d494687ee7f2e821d50b1ac63728a5c56f56d41da8d508015c03359ae": {}, - "ba67580f9ace0b3a538e32604bc26ac5c81c1f68e46e9d4cd81cc3f9cd7019ce": {}, - "bac7b5bfc8b0adba152bda164616d47e3e9ca8c95d4dbbcb50545cd0af23216f": {}, - "bad9c1fc8345c31df5af1bafc8ff4f6301be18297ce09696d9a599f2bc4a369f": {}, - "bcce7e1047e1b07f23f56bfc9fbfee33af6f35696ccb516bf13db41688d12d1b": {}, - "bcdf59b2e64912ca8d8238b476d40de0073aaa0faca249b30130394376fbe4cf": {}, - "bdaa76d37810e4d4ad1d2e7ac6e198ad96c48683e552e874901a7af3d8ae4e50": {}, - "be01b70f888c47b2031a23e85b7ded702c99fb6371e7b60255ffe539457973d0": {}, - "be604bfdca96a0e4d268f3b7c0a29820daabbb91192a79fc5819a2c012f312fb": {}, - "bf4ef5a431d9906c6af59ac95371579ee8f3afefaa9bce2c1f9352109f7b182b": {}, - "bfdbea33b3025d53035521a218ade8c796d0c780975b8f79c09f9e9253100b8a": {}, - "c02bb5cff013bf0749c345fbe1144fdd18e759efb3eb5ac66a68f2012c77f974": {}, - "c375a00e0ab9e41d86d0b2e2c794da72160c7437b79e0f4a51ff70814e760c01": {}, - "c476aef94b8840902a97cd38d37f966e4338a72ed79540c6ff20125b32cbf038": {}, - "c548de3c0e2cdf560345d5cf010078242f096a278c5d93bdd0331042a810447e": {}, - "c626a9ebc81aa9b6b75bab9563a03c97846e4eb975a98e4c54ac1513d0f87952": {}, - "c649c3c5b1d785a00e4bb839c5d0bfb6b1190cea794b703dd3472f3134e9bae6": {}, - "c86f2dc7a83d56fce03e80e289151ab23279e1c01be9bf2a770dd2528591a8a4": {}, - "c973d0ca494fc62c06c53bb0638888c0c776552deea8090df707f464346d7780": {}, - "ca39c397ccc1ac2d8fcb175cc47d7377c8b7f4aa6f1366d5fc027009cda92d51": {}, - "cb48379c00d6ef28a3e9a7c34cb444f7f068b35345186a7647194abc715a39ea": {}, - "ce3590e5e33a67073e096917e4f25dcf6efc983a9f952372f131f6059455ca5a": {}, - "cf23a22b693e4f3b3714ddb721fffa981f92d47420686bb814abd6014658abd6": {}, - "cfcc6767d5b0cb4cbfb1f7aeca7ccdbc1b317f175222ffc572eb491f1c619264": {}, - "d0af040eea426592b29f4de05650e6f9659ed23697272a5e2e45e18970a59e94": {}, - "d0f079bf3d4b490a76aeced6ec27790372f5f11340795a4e31d41e47bda69a13": {}, - "d23b0b7ce81aae0c3b2f3669648eafda68dc13ca0111347dc9960465f5fe3721": {}, - "d2c516e1b4d38b1a3215d38ab2d112ddbb5a684bd1ffbc07f4338322f10fee58": {}, - "d2f3887a130095e9bc802ceda8ed0b83c51248c1cb51b896cb966ee3a9638996": {}, - "d374686da4ab49cbf4ba6d2589a229d80c3c1e2d5f00a867803b3c5b9e11b2fc": {}, - "d3e22b16fe54e43b38579578d443bcf886f13812eb8d791e36b37e9e92e6e03b": {}, - "d4495edb0266dccdc77a8f1f6899f0921f56ba14e80163578d122dfd9f0251c4": {}, - "d4beda4e362e9a6c393af904fc0f6417add96b81566514e37217c017ce5c6a94": {}, - "d4d3f08fefd664c769c70b73bd9b33b28804987f5480f563ee230384d23aee5f": {}, - "d5ac3c8472df87a653bb8ccd5697d94e3525db64b3580822dfd092adc74b0cd2": {}, - "d5d591d9e903d63221a818ad176237129a1f04bc3df229a4bc657163730aeb37": {}, - "d6c4c80037ef1102f569193b117c4e2844136656de105f8d16a4cd05556d1d94": {}, - "d6d173d5ca721bc35f570a95d4f3cf5f1508e491d3aa904a37b5ee693148243c": {}, - "d7d88a4f989e27bef43ea115be3a7f72d6cca332e1b40b98a22f58280f8a86d3": {}, - "da4c26adf24970b88b3e7cb5d9af70479a065172308c96aa8e721b09018780d2": {}, - "daa4be16f9b5a693fac4c7b9f6424c19c558e24ae553fdc218fe49b5ed3ffbb0": {}, - "db32b08b6da79c546fbc22829ca1a40cba90a165857e64bfe98c6d55c840785a": {}, - "dd0937b09ab5c56677ecc9a42f2f8907df8e6b2818497cc3aada5d90a302e413": {}, - "dd1c0eff0557aeaa33fcfea188ee17770a708cb810896786b90293a254b20cdd": {}, - "dd5ff67809bac17da548fea22d2f3df60fc9c27d3cee3e9f4962fdea19717bd1": {}, - "ddd6e44aa204ad1e4fca370602b67bd07395a04b5330b37ed5bbed3648ca823b": {}, - "de761fd6c3c65de80fd53aadfaac5558eb5fc4c1f2f0719da2e69ffc81e1e671": {}, - "de878731e9d89bc576a3e4f536a05b7cc7ddf499a61c1d2efc4362a53d6f69d7": {}, - "df14cc18d2b940b04984ed2a9e6b8f059d9672569b56fc4e335a300a3215eb7b": {}, - "df369c267b4fbc9fade52cc7dbddfbeffc824c0aa92976f57894062c0166583a": {}, - "df39a1ff2afe96d05e40f842e7947fd4dc965fd02276a38890d17677c1210c5d": {}, - "df405bdb25adc2b339de95900097b7f9b2243ef34948b7a62180856ebdbd7c69": {}, - "e04585ba078bda9864c9f79f39f399b7c755ee61b1e89d3b37e1a4505634ebea": {}, - "e0c901fef09db972399e8d4fd746c881bbe9a600e9605a27c5f0de8c36e8da9e": {}, - "e0e5c9bbbe3eaf651f132ecc1a70bf020be80b85541922a28e633d579cd583cb": {}, - "e123277358af81e7ede30e21956187597413a63a4e31c7931dc7dd80c153079a": {}, - "e1cf014d84565d434119a84c746b43a3b96ca6bf1ad46b94d87e376c683b141e": {}, - "e216f36276646b01f5e5af7bced7765a18870e8b5a7b0827938cdac8b5d97144": {}, - "e2d2f23a7291b8cdbbffffc516e433a30c537f3c4f042f9a5aca924d57cc0ea3": {}, - "e4509b664b566577ba75342b678e42db37c9e34a3f7c709b380781f9b0858e14": {}, - "e48c53c547ce715cbc7c317db2fb47283c21a1fbff749c2fb3e5154343f128fd": {}, - "e537bf41df80639d8fa4365a01782d462e6d70a1eafc0d62d33ae05665ca3c7f": {}, - "e6f6357517b4ad5c6c5eadd6e815ea9c9a6396eb17e592767d8ac63139e43171": {}, - "e7bf874b1dd2c290891462f151a78a5d21895cb4a9c85ffaf1207515542698dc": {}, - "ea5b5adec8f845a5a136e621f4706a4b463366b01b3bd08be379ec9159ce4f8c": {}, - "eaa48590e23456cff55b5984c74dea5517604c2181dd343e3708933e29995474": {}, - "ed04a76cc2cd870c63a2079577e4e6a03fa57da207ceb0eee5d8f4ea7040ad1f": {}, - "ee080b9bc0be3fef066e83f5d5637fbd45fb606ac8903285a139d8505ccddf11": {}, - "ef530069ea353acb0e0c39783179d7987fbc4c886fefa52ad6029deeec10d2f0": {}, - "f084b3a6f67a553a2036f11f2d0d8e548fe8f82ff43105c6f81845cf0178ea9f": {}, - "f1634626cdc61a8f9db3698f98838655f88a0b5110a9302f4921566a056e101e": {}, - "f1a68bec50b173fda18d5c1123bd6b6816e3012d11098771ef86ad35aede7912": {}, - "f1c2d97b87ec7ed1b58e8b2b70dd3263e958e6b02a45477ef9b2c5f98f53df25": {}, - "f3a927f02914c077f1c4d0f0cd983657594a27b0caff417c6423cd948988023a": {}, - "f44c4c0db09e3cf83836a0df6b07e99ef52f8d044332cd34f95e57bf6a0e7f79": {}, - "f44e4d36066cb632ad90009562e2bb1d6b8e756a1a3ad7ce3640decf5384fed3": {}, - "f54b7b6d83112b7f7e052e296110638d22e142fa55ec651afd0f4122918b21fc": {}, - "f57bfe0a55bda9869af2c745edfe30c153f31151daf2adda97d1927c13b2504a": {}, - "f5a8acfbff99d5c877797148e2a62a24824b10343458ddb7771e73577ab3c401": {}, - "f7f3763c2af4c78dcb4c8b5b4ee541694485b7b3775eef2d73485bb94cea0cdc": {}, - "fac0e4c4e2034b9935f001cf40f773f8bd1cd020d536c8285c96b16fa5bec080": {}, - "fb34ed0517114c74cd07dcbb7801f6068e57a0c8d69daa0440650f32a7e6d0d1": {}, - "fb650d0b87814550b9cc20e8fdfc72198bb13bbecd74ad29d9849aab039d0256": {}, - "fbe30acda03286f5a508f9258ec69d16546877f80a7441b8bf8162f9838e8bb5": {}, - "fbeceb34ad69a2ccc7cd14fab344c999b3b52462ee9100469afccba1cb24d015": {}, - "fc0c848b374836b30670aeab884e4ae89da54c13ea586e10ac2d0a2ed452b99a": {}, - "fcbee4c653cb3695c31817b0fb8ca1287fbb67fa8ad72801b520eae222878536": {} - }, - "stored_height": 346, - "submarine_swaps": {}, - "transactions": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525": "0200000000010138f2739cddbcf1174fa5c45954f0cf58b20e7d615adc0b2c94760615d179dda30000000000fdffffff0200e1f505000000001600140ee9c5e6cd5ec4e9fed7f4814a97cc7393a86e2773170d8f0000000016001488891e9711881e89949d28149c2a739194a07eaf0247304402206a3a9246914161173ce34f16ab1b8372f24f6ba571a9c4798c40aa03d885232f022005c311fe4f4165eab166fd14d290b3741be1dde4db0c265bf0206f4da1f3f2650121034b46532f35ff9ca13b9e0353f3f03155fc3e8f29ec64e76f273e0bab9b5f728f2f010000", - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": "0200000000010125259c68b4233c649d01acd408af926ca8eaa1d4ef984205bb99499f01a514190000000000fdffffff0192e0f505000000001600141729c7a5013028caa87a57729c28d29eb79ba0570247304402207648fc992853fabfdc8b0e33e72ee280c7a5d51e43797deddc99a8f0257a807502202f534a84069313d1df46f661cf3f06c7b3abe86d2625d393da61cab17ce15efb012103e54b7296660957e79d32d308251effd390ca91ad93ea158b6da0c339bb0b381934010000" - }, - "tx_batches": {}, - "tx_fees": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525": [ - null, - false, - 1 - ], - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": [ - 110, - true, - 1 - ] - }, - "txi": { - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": { - "bcrt1qpm5utekdtmzwnlkh7jq5497vwwf6sm38tljan5": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525:0": 100000000 - } - } - }, - "txo": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525": { - "bcrt1qpm5utekdtmzwnlkh7jq5497vwwf6sm38tljan5": { - "0": [ - 100000000, - false - ] - } - }, - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": { - "bcrt1qzu5u0fgpxq5v42r62aefc2xjn6mehgzhtj4pld": { - "0": [ - 99999890, - false - ] - } - } - }, - "use_encryption": false, - "verified_tx3": { - "1914a5019f4999bb054298efd4a1eaa86c92af08d4ac019d643c23b4689c2525": [ - 304, - 1787140092, - 1, - "6a683af37daddd75ee0e50d6df16975bdac46eea817a7514980579a94c12addc" - ], - "f2b1e58f58c7cf2b71d5d51b2abcf78d9a7a3493377dd29c1e4d4c2f89c3b4a1": [ - 309, - 1787143092, - 1, - "45d3b2e4485d5d61391f1f102129d5c4cd9bb08536e82f0c364606ec1723f58b" - ] - }, - "wallet_type": "standard", - "will": { - "12664cab862cb5c1c6ba1b4d44a79e0c23ce4410c5685d578585e01a29a4c7f0": { - "ANTICIPATED": false, - "BROADCASTED": false, - "CHECKED": false, - "CHECK_FAIL": false, - "COMPLETE": true, - "CONFIRMED": false, - "ERROR": false, - "EXPIRED": false, - "EXPORTED": false, - "IMPORTED": false, - "INVALIDATED": false, - "MEMPOOL": false, - "PARTIALLY_SIGNED": false, - "PUSHED": false, - "PUSH_FAIL": false, - "REPLACED": false, - "RESTORED": false, - "UPDATED": false, - "VALID": true, - "_id": "12664cab862cb5c1c6ba1b4d44a79e0c23ce4410c5685d578585e01a29a4c7f0", - "baltx_fees": 1, - "change": null, - "description": "mario2\nop_return\nop_return2\naaaa\nlucia\nmario", - "heirs": { - "aaaa": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - "34%", - "2d", - 20197899 - ], - "lucia": [ - "bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j", - "32%", - "2d", - 19009787 - ], - "mario": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - "35%", - "2d", - 20791955 - ], - "mario2": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - 40000000, - "2d", - 40000002, - 40000000 - ], - "op_return": [ - "OP_RETURN:48656c6c6f", - "0", - "2d", - 0, - 0 - ], - "op_return2": [ - "OP_RETURN:426974636f696e2041667465726c696665", - "0", - "2d", - 0, - 0 - ] - }, - "sigs_have": 1, - "sigs_required": 1, - "status": "New.Signed", - "time": 1787142956.7950864, - "tx": "02000000000101a1b4c3892f4c4d1e9cd27d3793347a9a8df7bc2a1bd5d5712bcfc7588fe5b1f20000000000fdffffff060000000000000000076a0548656c6c6f0000000000000000136a11426974636f696e2041667465726c696665fb102201000000001600147e19af296f25d092f23a0c208823e65c81a2af9d0b323401000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc93423d01000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc025a6202000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc02473044022053948fa3fb18a55a273cbe911e0bdc1f392b2a7d49d6b30ac11aed468ed0eca80220340dda19721e3acff98ad7b5cca82ca184e7338a09ab9e48fccccf4092ac4b07012102cbd3f0fcddf1302e7e4e54814f860ec4d770c512666eb86bb2e76900f8cfeb3740cd876a", - "willexecutor": null - }, - "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d": { - "ANTICIPATED": false, - "BROADCASTED": false, - "CHECKED": true, - "CHECK_FAIL": false, - "COMPLETE": true, - "CONFIRMED": false, - "ERROR": false, - "EXPIRED": false, - "EXPORTED": false, - "IMPORTED": false, - "INVALIDATED": false, - "MEMPOOL": false, - "PARTIALLY_SIGNED": false, - "PUSHED": true, - "PUSH_FAIL": false, - "REPLACED": false, - "RESTORED": false, - "UPDATED": false, - "VALID": true, - "_id": "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d", - "baltx_fees": 1, - "change": null, - "description": "w!ll3x3c\"http://localhost:9133\"1787284800\nmario2\nop_return\nop_return2\naaaa\nlucia\nmario", - "heirs": { - "aaaa": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - "34%", - "2d", - 20197552 - ], - "lucia": [ - "bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j", - "32%", - "2d", - 19009461 - ], - "mario": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - "35%", - "2d", - 20791598 - ], - "mario2": [ - "bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju", - 40000000, - "2d", - 40000001, - 40000000 - ], - "op_return": [ - "OP_RETURN:48656c6c6f", - "0", - "2d", - 0, - 0 - ], - "op_return2": [ - "OP_RETURN:426974636f696e2041667465726c696665", - "0", - "2d", - 0, - 0 - ], - "w!ll3x3c\"http://localhost:9133\"1787284800": [ - "bcrt1qud7q9wtvveelk3f9876p56neeexa92l8s6g8d8", - 1000, - 1787284800, - 1000 - ] - }, - "sigs_have": 1, - "sigs_required": 1, - "status": "New.Signed.Pushed.Checked", - "time": 1787142956.7950864, - "tx": "02000000000101a1b4c3892f4c4d1e9cd27d3793347a9a8df7bc2a1bd5d5712bcfc7588fe5b1f20000000000fdffffff070000000000000000076a0548656c6c6f0000000000000000136a11426974636f696e2041667465726c696665e803000000000000160014e37c02b96c6673fb45253fb41a6a79ce4dd2abe7b50f2201000000001600147e19af296f25d092f23a0c208823e65c81a2af9db0303401000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc2e413d01000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc015a6202000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc02473044022042329ac8d54a5e7fc76b6a8d446496c2bdef4672e778d376797e8244bab6867002205435529afce046a7ce471734b4e4a364e319894f4165a01a6f43c9cfa8c8a41d012102cbd3f0fcddf1302e7e4e54814f860ec4d770c512666eb86bb2e76900f8cfeb3740cd876a", - "willexecutor": { - "address": "bcrt1qud7q9wtvveelk3f9876p56neeexa92l8s6g8d8", - "balance": 90000, - "base_fee": 1000, - "broadcast_status": "Success", - "chain": "regtest", - "count_win": 0, - "id": 66, - "info": "BAL devel willexecutor server", - "last_block": 0, - "last_update": 1787140211.775144, - "onion_url": null, - "points": 0, - "promo_code": null, - "selected": true, - "status": 200, - "tld": "localhost", - "txs": "02000000000101a1b4c3892f4c4d1e9cd27d3793347a9a8df7bc2a1bd5d5712bcfc7588fe5b1f20000000000fdffffff070000000000000000076a0548656c6c6f0000000000000000136a11426974636f696e2041667465726c696665e803000000000000160014e37c02b96c6673fb45253fb41a6a79ce4dd2abe7b50f2201000000001600147e19af296f25d092f23a0c208823e65c81a2af9db0303401000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc2e413d01000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc015a6202000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc02473044022042329ac8d54a5e7fc76b6a8d446496c2bdef4672e778d376797e8244bab6867002205435529afce046a7ce471734b4e4a364e319894f4165a01a6f43c9cfa8c8a41d012102cbd3f0fcddf1302e7e4e54814f860ec4d770c512666eb86bb2e76900f8cfeb3740cd876a\n", - "txsids": [ - "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" - ], - "unconfirmed_balance": 0, - "url": "http://localhost:9133", - "version": "0.3.2" - } - } - }, - "will_settings": { - "baltx_fees": 1, - "locktime": "2y", - "thershold": "150d", - "threshold": "150d" - }, - "winpos-qt": [ - 0, - 69, - 1000, - 400 - ] -} \ No newline at end of file From b3624fef1cb50ad4d3e34bc951b9327872658472 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Wed, 9 Sep 2026 08:43:32 -0400 Subject: [PATCH 3/5] 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) From d5f30e89b9b9e18449f6f5048fa96a4f9815a3b2 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Wed, 9 Sep 2026 08:43:39 -0400 Subject: [PATCH 4/5] docs: QR/audio will transfer notes --- CHANGELOG.md | 170 +++++++++++++++++++++++++++++++++++++++++++++++ COMPATIBILITY.md | 19 ++++++ HANDOFF.md | 81 ++++++++++++++++++---- README.md | 17 ++++- 4 files changed, 272 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f00c60..b81787a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3094,4 +3094,174 @@ misleading. - Manually tested by the owner in Electrum 4.8.1: `NO_FUTURE_DATE`, `WILLEXECUTOR_FEE` and `No Heirs` were all confirmed on screen. +## 57. Remove all `copy.deepcopy` (ad-hoc copy helpers; `WillItem` copies serialize/deserialize) + +**Date:** 2026-08-28 + +**Goal (owner request):** eliminate every `copy.deepcopy` from the codebase +and replace it with ad-hoc copy methods; `WillItem` copies must be produced by +serializing and deserializing the item rather than by deep-copying live +runtime objects (which can hold a `threading.RLock` and cannot be pickled). + +**What changed:** + +- `bal/core/util.py`: new `copy_structure(value, _path="copy")` — the single + JSON-safe, deepcopy-free recursive cloner (dict / list / tuple cloned + structurally, JSON scalars kept as-is, any accidental runtime object coerced + to `str` + logged). It replaces the old `heirs._json_safe` implementation. +- `bal/core/heirs.py`: `_json_safe` is now a thin backward-compatible alias of + `bal.core.util.copy_structure`; `Heirs.save` behaviour is unchanged. +- `bal/core/will.py`: + - `WillItem.__init__` on a `WillItem` argument no longer does + `self.__dict__ = w.__dict__.copy()` + `copy.deepcopy`; instead it + serializes (`to_dict()`) and deserializes: the tx is re-parsed into a fresh + object, `STATUS` is rebuilt from a clone, and heirs / will-executors are + cloned recursively, so the copy shares no mutable state with the source. + - New `WillItem.copy(wallet=None)` (serialize/deserialize round trip; re-adds + wallet tx info when a wallet is passed) and the static + `WillItem.copy_status_table(table)` used for the `STATUS` tables. + - `to_dict()` now also emits `Father` / `Children` so the round trip is + faithful. + - `normalize_will` routes copies through the constructor / `copy()`. +- `bal/gui/qt/window.py` and `bal/cli/controller.py`: the Build-will flow now + uses `copy_structure(...)` instead of `copy.deepcopy(...)` for heirs and + will-executors. +- Dropped now-unused `import copy` (`will.py`, `controller.py`, `qt/common.py`, + `qt/window.py`). +- Tests updated to the same helpers: STATUS tables via + `WillItem.copy_status_table`, heirs / built dicts via `copy_structure` + (`test_core_will.py`, `test_core_will_invalidate.py`, + `test_heir_relative_anchor.py`, `test_anticipate_manual_locktime.py`, + `test_no_willexecutor_karen7.py`, `test_reproduce_none_type.py`, + `test_group_e_mock_karen7.py`, `test_group_e_karen7_invalidate.py`, + `sim_update_flows.py`). + +**Verification:** +- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 377 passed, + only the two pre-existing `test_bt_to_date_*` datetime compare failures + remain (identical to HEAD — no regression; `test_heir_relative_anchor` + isolated-file failure is pre-existing test-pollution at HEAD too). +- Ad-hoc semantics check: `copy()`/ctor copy share no mutable state with the + source (mutating source heirs/STATUS does not leak into the copy and vice + versa), `copy_status_table` returns fresh lists, `normalize_will` runs. +- `ruff` on all touched files: no new violations (4 findings, all pre-existing + at HEAD). +- `tests/smoke_test.py electrum.plugins.bal`: passed. +- `python3 build_zip.py`: 45 files, 343591 bytes, sha256 `aa8f8154…`; + `tests/external_zip_test.py bal-electrum-plugin.zip`: passed (Plugin class + loads via the zipimport shim). + **Outcome:** DONE. + +--- + +## Next. Animated-QR interop (BC-UR v1/v2, BBQR) + +**Date:** 2026-09-08 + +**Goal:** Let BAL export/import a will not only as its own BAL QR frame format +but also as BC-UR v1 (`ur:bytes`, BC32 + SHA-256), BC-UR v2 (`ur:bytes`, CBOR +bytewords-minimal fountain codes) and BBQR (Coinkite `B$…`) animated-QR +sequences, so transfers interoperate with Blockchain Commons / Coldcard-style +tools and BitKit. Codecs must be stdlib-only and the export must keep BAL QR +as the default. + +**What changed:** + +- `bal/core/animated_qr.py` (new): stdlib-only codec module. + - BC32 (bech32_bis checksum, XOR `0x3FFFFFFF`) encode/decode matching the + BCR-2020-004/005 reference vectors. + - bytewords-minimal encode/decode (BCR-2020-012) with CRC-32 rejection; + the word list was transcribed verbatim from the reference C++. + - BC-UR v2: CBOR part writer/reader, CRC-32, `choose_fragments` + (xoshiro256** + alias + ary-threshold sampler) and XOR-based fountain + mixing/solving; emits a redundant mixed wave for loss tolerance. + - BC-UR v1: multipart with SHA-256 digest and single-part digest-less + frames; `1of1` handling. + - BBQR: base32 (encoding `2`), hex (uppercase, `H`) and zlib (lowercase, + `Z`, automatic compression fallback) frames; out-of-order reconstruction. + - One `AnimatedQrSession` + `detect_format` + `parse_for_detection` for + auto-detecting the incoming format and keying the GUI debounce. + - Safety caps: `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`, + zlib-bomb guard, `TransferConflictError`/`SessionLimitError`. +- `bal/gui/qt/dialogs.py`: + - Export page (`BalQrExportWidget`) gained a **Format** selector + (BAL QR default, BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets, + with per-format intro/format-hint text. + - Import page (`BalQrImportWidget`) now routes every frame through + `parse_for_detection` + `AnimatedQrSession.add_part`, auto-detecting the + format and resetting when the transfer's session key changes; the + review/sign step resolves the session and decodes parts uniformly. + - `qr_import_accept_frame` generalised to + `(state, fmt, session_key, frame_total, index, payload, stable_reads=2)`. +- `tests/test_core_animated_qr.py` (new, 32 tests): BC32 spec vectors, + bytewords round-trip/CRC, C++ reference-frame decode+re-encode parity + (single-part 12B, seq_len=2, seq_len=7), fountain solve with missing pure + part, out-of-order/duplicate handling, single/multipart UR v1, BBQR + Z/2/H round-trips, runt last part, zlib-bomb guard, detection positive/ + negative. + +**Verification:** + +- `tests/test_core_animated_qr.py`: 32/32 pass. +- `tests/test_gui_qr_transfer.py` (now 36 tests) + `test_gui_export_dialogs.py`: pass. +- `ruff` clean on `animated_qr.py`, `dialogs.py` and both test files; + `pyright` 0 errors on the touched modules. +- `tests/smoke_test.py electrum.plugins.bal`, `python3 build_zip.py` and + `external_zip_test.py` all pass. +- Full regression: 462 passed; only pre-existing failures remain + (`test_bt_to_date_*`, will-invalidate fee, unrelated `sign_transactions` + stub test). + +**Notes / caveats:** + +- A real bug was found & fixed during this work: `_ur2_part_cost` used + `2 * body_len` but `bytewords_minimal_encode` appends a 4-byte CRC, so every + UR v2 frame was undercounted by 8 characters and could overflow the QR + budget for large transfers. +- UR v1 multipart emits the digest-carrying `1of1//` form for a + single part (both headered and headerless single parts are accepted on + import); this keeps deterministic digest verification. +- Imported payloads are UTF-8 text; the codec sessions do not decode raw + binary transfer blobs. + +**Outcome:** DONE (uncommitted). + +--- + +## Animated-QR bugfix: QVideoSink signal wiring + will-export JSON crash + +**Date:** 2026-09-08 + +**Goal:** Fix two runtime crashes found by manual testing of the QR paths. + +**What changed:** + +- `bal/gui/qt/dialogs.py`: + - `_start_scan`/`_stop_scan` used `QVideoSink.videoFrame.connect/.disconnect`, + but on PyQt6 `videoFrame` is the frame **getter method**, not a signal — + this raised ``AttributeError: 'builtin_function_or_method' object has no + attribute 'connect'`` on camera scan. Switched to the `videoFrameChanged` + signal (same wiring Electrum's `QrReaderVideoSurface` uses). + - `_stop_scan` now tolerates `AttributeError` when disconnecting the sink + and guards the `errorOccurred` disconnect too, so a mid-init failure can + never cascade into a second uncaught exception. + - `_whole_will_json` (whole-will QR export) serialized ``WillItem.to_dict()`` + with plain `json.dumps`, crashing with ``TypeError: Object of type + Transaction is not JSON serializable`` (the ``tx`` field holds a real + ``Transaction``). Now uses Electrum's `MyEncoder`, matching `write_json_file`. +- `tests/test_gui_qr_transfer.py`: new `test_import_start_stop_scan_signal_wiring` + drives the real `QVideoSink` life-cycle with a mocked camera and fails if + the signal name regresses to `videoFrame`. +- `tests/test_gui_export_dialogs.py`: new + `test_qr_whole_will_json_serializes_transaction` covers the JSON export. + +**Verification:** + +- `pytest tests/test_gui_qr_transfer.py tests/test_gui_export_dialogs.py -q`: pass. +- Full offline batch `tests/test_core_*.py tests/test_gui_*.py`: 444 passed. +- Regression test flips correctly (fails when reverted to the buggy call). +- `ruff` clean on touched files; `tests/smoke_test.py`, `build_zip.py`, + `external_zip_test.py` all pass. + +**Outcome:** DONE (uncommitted). diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 8606368..d5db6e7 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -23,6 +23,25 @@ is updated to mark them as supported. See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2 and 4.8.0). +## QR wire-format compatibility + +BAL exports/imports wills as QR codes. **BAL QR** (the default) is the plugin's +own frame format and is only understood by BAL itself. The export page also +supports **BC-UR v1**, **BC-UR v2** and **BBQR**: + +| Format | Wire appearance | Interop target | +|-----------|----------------------------|------------------------------------------------------| +| BAL QR | `BALQR1\|total\|index\|…` | Past/other BAL versions (default, always exported) | +| BC-UR v1 | `ur:bytes/` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) | +| BC-UR v2 | `ur:bytes/-/` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) | +| BBQR | `B$…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode | + +Import auto-detects the format of each scanned code; out-of-order, duplicate +and (for UR v2) partially-lost fountain frames are handled. Interop is +validation-tested against the reference C++ bc-ur encoder output and the +BCR-2020-004/005 BC32 test vectors; it has not yet been cross-verified against +third-party libraries (`ur`, `bbqr`, Coldcard firmwares). + ## Reporting compatibility issues If you find a compatibility problem not listed here, please open an issue on diff --git a/HANDOFF.md b/HANDOFF.md index f639ab0..00d3612 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -395,19 +395,74 @@ See Section 5 for details. ### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`) -- P0–P4 of `PLAN_QR_TRANSFER.md` are implemented on - `feature/bal-qr-transfer` and un-committed: core scheduler - (`bal/core/qrtransfer.py`), settings row, export/import dialogs + - review/sign wizard + lists/window wiring, all with tests - (`tests/test_core_qr_transfer.py`, `tests/test_gui_qr_transfer.py`) and - docs (README, CHANGELOG entry 56, QML_PLAN, this file). -- Remaining: **P6** release hygiene — `python3 build_zip.py`, external-zip - test, full ruff check (no NEW violations; baseline is intentionally dirty), - and a manual on-device walkthrough of the QR path. The audio path is - optional and hidden until the `audio_modem` plugin + `amodem` are - installed. -- Commit policy unchanged: nothing is committed until the owner explicitly - confirms. +- The full QR-transfer feature (P0–P6) is implemented, tested and committed on + `feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to + `origin`). PR creation URL: + `https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer` +- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE` + setting (4 export presets), export/import dialogs + review/sign wizard + + lists/window wiring, export filters, auto slideshow with per-second rate + + loop option, audio send/receive buttons, and the crash fixes + (`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry + 56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`. +- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed — + `copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor + serialize/deserialize, `copy_status_table()`. Working tree clean after the + branch's three commits. +- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*` + failures; ruff no new violations; smoke + `build_zip.py` + + external-zip OK; pyright clean. The isolated + `test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired` + failure is pre-existing test pollution (fails identically on clean HEAD, + passes inside the full batch) — not caused by entry 57. +- Remaining: manual on-device walkthrough of the QR path (and, if wanted, + the audio path — buttons only appear when the `audio_modem` plugin + + `amodem` are installed; see the prerequisites below). + +### In progress: animated-QR interop (BC-UR v1/v2, BBQR) + +- `bal/core/animated_qr.py` implements stdlib-only codecs for **BC-UR v1** + (BC32 + SHA-256 digest; the bech32_bis checksum variant per + BCR-2020-004/005), **BC-UR v2** (CBOR part structure, bytewords-minimal, + CRC-32, xoshiro256-based fountain with alias-sampled mixing) and **BBQR** + (Coinkite `B$…` base32/hex/zlib frames), plus one shared + `AnimatedQrSession` with `detect_format` auto-detection and + `parse_for_detection` frame identity for the GUI debounce. +- Current status as of this session: reference parity, GUI, and tests done; + not yet committed. + - **BC32/bytewords/codec parity:** BC32 reproduces the BCR-2020-004/005 + test vectors (`Hello, world`, `Hello world`, the long seed vector); + bytewords-minimal round-trips with CRC rejection; UR v2 part encode + + decode is byte-exact against the reference C++ bc-ur encoder for a + single part, seq_len=2 (12 frames) and seq_len=7 (3 sampled mixes), + validating CBOR framing, bytewords, alias+ary-threshold sampling, + xoshiro256** and the XOR mix. + - **Sessions:** UR v2 single-part (no seq header), out-of-order frames, + duplicate drops, solve with a missing pure fragment (a second redundant + mixed wave is emitted by `ur2_frames`), UR v1 single-part + (digest-less `ur:bytes/` accepted) and multipart, BBQR full-frame + decode in any order for Z/2/H encodings. + - **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`, + `TransferConflictError` on a frame from a different transfer, + `SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only. + - **GUI:** `BalQrExportWidget` gained a Format selector (BAL QR default, + BC-UR v1, BC-UR v2, BBQR) reusing the QR-size presets; the importer now + routes every frame through `detect_format` + + `AnimatedQrSession.add_part` with the shared + `qr_import_accept_frame(state, fmt, session_key, frame_total, index, + payload, stable_reads=2)` debounce (reset on session-key change). + `_review_and_sign` resolves the session to the transfer text and decodes + parts uniformly across formats. + - **Verification:** `tests/test_core_animated_qr.py` (32 tests incl. the + C++-reference parity vectors and BC32 spec vectors) and the extended + `tests/test_gui_qr_transfer.py` pass; ruff clean on the new/changed + files; pyright 0 errors; smoke test, `build_zip.py` and + `external_zip_test.py` green. Only the pre-existing failures remain + (`test_bt_to_date_*`, fee-exceeds-balance, karen7 pollution). + - **Any remaining work:** manual on-device walkthrough of the QR path with + the new formats; optionally validate against third-party libraries + (`ur`, `bbqr`) once available; add the docstrings/branch notes already + captured in `ag1.md`/`ag2.md` context where needed. **Dev-box audio prerequisites (audio_modem channel):** See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the diff --git a/README.md b/README.md index c9bb548..706499f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ bal/ the installable Electrum plugin package │ ├── willexecutors.py │ ├── checkalive.py │ ├── reminders.py -│ ├── qrtransfer.py QR will-transfer wire format / chunk scheduler +│ ├── qrtransfer.py BAL QR will-transfer wire format / chunk scheduler +│ ├── animated_qr.py BC-UR v1/v2 + BBQR codecs (stdlib-only) │ └── input_rules.py ├── cli/ headless command-line layer (no Qt) │ ├── commands.py bal_* daemon commands (@plugin_command) @@ -97,7 +98,19 @@ export offers All / Valid / Valid-NC filters plus a QR size preset one at a time, then proposes exporting the signed transactions. When Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` + PortAudio) Send/Receive audio buttons complement the QR channel. See -[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the full wire-format spec. +[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the BAL QR wire-format spec. + +### Animated-QR formats (interop) + +BAL QR is the default export format, but the export page's **Format** selector +also emits **BC-UR v1** (`ur:bytes`, BC32 + SHA-256), **BC-UR v2** +(`ur:bytes`, CBOR fountain codes) and **BBQR** (`B$…`, Coinkite, used by +BitKit) animated-QR sequences. The importer auto-detects the format of each +code it sees, so any of the four formats can be imported on a BAL device, and +a BAL export can be imported by any tool that understands these standards. +UR v2 imports tolerate out-of-order and duplicate frames (fountain decoding); +BBQR frames may arrive in any order. Rotation/redundancy caps and the +32 MB message limit (zlib-bomb guard) bound untrusted scanner input. ## Command-line / headless usage From 7c9bbfce58f61e3708197643e3b7635e4da23f3c Mon Sep 17 00:00:00 2001 From: svatantrya Date: Thu, 10 Sep 2026 17:07:22 -0400 Subject: [PATCH 5/5] core+gui+cli: anticipation/rebuild dates, preserve relative settings Fixes around the delivery/build/check date handling (karen7 regtest): - build_will (GUI + CLI) re-anchors date_to_check to the CURRENT heirs' earliest future delivery before building, so an anticipated rebuild is no longer blocked by the stale old-will anchor (NO_FUTURE_DATE). The checks of the existing will keep their anchored date_to_check. - _sync_locktime_to_built_txs now PRESERVES RELATIVE locktime/threshold recipes ("2y"/"150d") in WILL_SETTINGS instead of freezing them to absolute timestamps: the anchored comparisons (resolve_locktime_against_tx and resolve_date_to_check with built_locktime) already prevent the daily invalidate prompt. Absolute values still sync on a genuine automatic anticipation. - Will.remove_stale_wallet_history drops stale wallet-LOCAL will placeholders before every (re)build in GUI and CLI so their coins are available again. - check_willexecutors_and_heirs raises HeirNotFoundException outside the count_heirs gate: a shortened relative recipe on a signed will now triggers a plain rebuild ("no heirs" only when there really are none). - is_locktime_below_threshold compares the settings on one reference frame (resolve_guard_threshold), no longer against the built-will anchor. Tests: purge unit + call-site, no-heirs, guard, anticipated-rebuild end-to-end (GUI) + CLI mirror + fixed_percent_lists_amount unit, relative-preserve sync; conftest restores electrum.constants.net after each test (cross-file pollution guard). --- QML_PLAN.md | 379 ------------------------- bal/cli/controller.py | 31 +- bal/core/checkalive.py | 48 ++++ bal/core/will.py | 49 +++- bal/gui/qt/dialogs.py | 103 +++---- bal/gui/qt/window.py | 55 +++- tests/conftest.py | 18 ++ tests/test_cli_controller_offline.py | 36 +++ tests/test_core_checkalive.py | 71 +++++ tests/test_core_heirs.py | 27 ++ tests/test_core_will.py | 61 +++- tests/test_core_will_extra.py | 73 +++++ tests/test_gui_prepare_will_history.py | 41 +++ tests/test_gui_will_flows.py | 104 +++++++ tests/test_heir_relative_anchor.py | 61 ++-- tests/test_sync_locktime_built_txs.py | 89 +++--- 16 files changed, 724 insertions(+), 522 deletions(-) delete mode 100644 QML_PLAN.md create mode 100644 tests/conftest.py diff --git a/QML_PLAN.md b/QML_PLAN.md deleted file mode 100644 index be00a56..0000000 --- a/QML_PLAN.md +++ /dev/null @@ -1,379 +0,0 @@ -# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support) - -> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is -> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will -> transactions from an online machine (desktop/another phone), sign them with -> the wallet keys held on it, and return the signed transactions — a classic -> air-gapped signer workflow. Online features (willexecutor contact, -> broadcast, build) are secondary on Android and belong mainly to the online -> machine. -> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic -> through the existing GUI-free `BalController`, exactly like `bal/cli/` -> already does. The PyQt6 desktop GUI remains untouched and primary. -> -> Status: DRAFT for owner review (rule R4 — no code until explicit OK). -> Chat language: Italian; this document is in English per rule R1. - ---- - -## 1. Verified facts (research done on the local checkouts) - -All items below were verified by reading source, not assumed. - -| # | Fact | Where | -|---|------|-------| -| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` | -| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` | -| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `//qml/.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` | -| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `/home/steal/devel/bal/electrum470/electrum/gui/{qml,common_qt}` | -| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 | -| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` | -| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` | -| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` | -| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` | -| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 | -| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` | - ---- - -## 2. Scope - -### In scope (MVP) - -Two usage **profiles** share one codebase: - -- **Offline signer profile** (Android, PRIORITY): works with no network. - 1. Import a will bundle exported by the online machine (existing JSON will - format, see F12) — via file share, paste, or QR. - 2. Review what is being signed (destinations, amounts, locktimes, fees). - 3. Sign internally (`wallet.sign_transaction` + password prompt). - Partial signatures are combined when the bundle is re-imported - (`combine_with_other_psbt`, already supported by `merge_will` logic). - 4. Export the signed bundle back to the online machine. -- **Online manager profile** (desktop QML, secondary): - - Will status overview (state, expiry/check-alive date, reminder info). - - Heirs list (view/add/edit/remove, addresses or URIs). - - Will-executor selection (list, enable/disable, fee display, refresh). - - Build will (simplified wizard reusing core validation). - - Sign in place (password) or hand off to an offline signer via bundles. - - Broadcast / push to will-executors; invalidate will; check-alive refresh. - - Basic settings mapped onto `will_settings`. - -The offline signer pages are built first and must function with the network -disabled (Electrum runs fine offline; willexecutor refresh simply degrades). - -Also in scope: - -- Android packaging: BAL bundled as internal plugin in the custom APK, - **enabled by default** (owner decision D3). -- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged. - -### Out of scope (explicitly deferred) - -- Full parity with the PyQt6 GUI (calendar widget, preview list editor, - advanced fee controls, themes). -- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP - builds keep working for desktop exactly as today, without `qml` UI). -- iOS, upstream-Electrum (spesmilo) compatibility. -- Lightning-related features (irrelevant to BAL). - ---- - -## 3. Architecture - -``` - ┌───────────────────────────────────────────┐ - │ bal/core │ - │ heirs, will, willexecutors, checkalive, │ - │ reminders, input_rules, plugin_base │ - └────────────┬──────────────────────────────┘ - │ (no Qt anywhere) - ┌─────────────────────┼──────────────────────┐ - ▼ ▼ ▼ - bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW - BalWindow etc. BalController qml_plugin.py (BalQmlPlugin) - (~9.3k lines) (headless flows) models.py (QObject VMs) - so.py (PluginQObject) - *.qml (views) - ▲ - wraps ONE BalController - per loaded wallet -``` - -Design rules: - -- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject - wrappers around one `BalController` instance per wallet. No business logic - in QML or in the wrappers beyond formatting. -- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`) - are registered by `bal/core/plugin_base.py` already; QML reads/writes them - through the controller, so a wallet moves between desktop/Android unchanged. -- **Threading.** Network operations (willexecutor fetch/push, broadcast) - run in worker threads exactly as the CLI does; results marshalled to the UI - thread via Qt signals on the wrapper objects. No blocking calls in slots. -- **One transfer format.** The airgap round trip reuses the existing JSON - will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's - `export_json_file`/`import` + `merge_will` flow already produces and - consumes — see F12). No new format is invented; export/merge logic gets a - single shared home usable by both frontends. -- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and - defensively; if absent (e.g., odd build), the plugin degrades to core-only - behavior instead of crashing the daemon. - ---- - -## 4. Work breakdown - -### Phase 0 — Verification spikes (no product code) - -| Task | Description | Exit criterion | -|------|-------------|----------------| -| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section | -| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash | -| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` | -| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file | - -Deliverable: short findings report appended to this document; go/no-go. - -### Phase 1 — Skeleton integration - -Files (all NEW unless noted): - -``` -bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py -bal/gui/qml/__init__.py package docstring -bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase) -bal/gui/qml/so.py class BalSignalObject(PluginQObject) -bal/manifest.json MODIFIED: available_for += ["qml"] -``` - -Details: - -- `qml_plugin.py`: - - `@hook init_qml(self, app)`: store app ref; create `so` parented to app; - for each already-loaded wallet call `_on_wallet_loaded(wallet)` - (mirrors labels' pattern, see F3). - - `@hook load_wallet(self, wallet)` — **single argument** (F9); creates the - per-wallet view-model bundle (Phase 2) keyed by `wallet`. - - `@hook unload_wallet(self, wallet)`: drop controllers, close windows. -- `so.py`: `BalSignalObject(PluginQObject)` exposing: - - `loader` property returning `"BalMain.qml"` (drives F3 auto-create); - - signals: `walletChanged`, `willStateChanged`, `heirsChanged`, - `willexecutorsChanged`, `busyChanged`; - - slots called from QML: open/close window, refresh willexecutors, - check-alive now, build/sign/broadcast/invalidate commands. -- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched - (Electrum filters per running GUI, verified F2). - -Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created -(log line from `onPluginLoaded`), no functional UI yet. - -### Phase 2 — View-models (QObject layer) - -New file `bal/gui/qml/models.py`: - -- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only - properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`, - `sigsHave/sigsRequired` per tx) + notification signals; forwards actions to - controller methods (`build_will`, `sign_transactions`, `broadcast_will`, - `invalidate_will_headless`, `check_alive`... — names per controller). - - **Airgap methods (priority):** `export_will_bundle()` and - `import_will_bundle(json_text)` returning summary of what changed. These - are small ports of the Qt GUI's `export_json_file` (window.py:1605) and - `merge_will` (window.py:1620) semantics. Preferred implementation: move - the logic into shared helpers (controller level or `bal/core/will.py` - static functions) and make the Qt GUI call the same helpers, so the two - frontends cannot diverge; regression-covered by existing core tests plus - new round-trip tests. - - Offline profile detection: expose an `isOffline` property derived from - `wallet.network is None` / config, so QML can hide online-only pages. -- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`, - `valid`; edit methods delegate to `Heirs` helpers through controller. -- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`, - `sigsHave`, `sigsRequired`, `isComplete`. -- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`, - `fee`, `valid`; toggle + async refresh. -- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer - planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt - plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL / - pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets; - re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR - rendering inside the model (QML paints it). -- All list mutations happen on the controller state then `beginResetModel/ - endResetModel` (datasets are small; simplicity over incremental updates). - -Exit criterion: pytest-driven model tests pass offscreen (create models over a -regtest/testnet wallet fixture, assert roles after mutations). - -### Phase 3 — QML views - -New directory `bal/gui/qml/components/`: - -``` -BalMain.qml top-level Window; stack of pages below -BalSignPage.qml PRIORITY (offline signer): import bundle - (paste / file / QR), review summary of each tx, - password-sign, export signed bundle back -BalQrExportPage.qml QR export view: drives BalQrTransferModel, one - frame at a time (Prev/Next, progress i/N, chunk - preset selector), mirror of desktop Qt dialog -BalQrImportPage.qml QR import view: slot grid (1..N), camera via - Electrum `QRScan` reuse, manual paste fallback, - then jump into BalSignPage review/sign -BalStatusPage.qml will state, expiry countdown, check-alive button - (works offline with last-known data) -BalHeirsPage.qml ListView + add/edit dialog [online profile] -BalExecutorsPage.qml ListView with switches + refresh [online profile] -BalBuildPage.qml simplified build form (threshold selector, fees, - executor pick) → runs controller.build_will - [online profile] -BalSettingsPage.qml maps onto will_settings subset -controls/BalButton.qml, BalField.qml minimal styled primitives -qmldir module registration -``` - -Notes: - -- **BalSignPage review screen is mandatory**: before signing, the user must - see per-transaction heir address, amount, locktime (date), and fee — this - device is the security boundary, so no "blind signing". -- QR transfer: a full will bundle may exceed one QR's capacity when there are - many heirs/transactions. The desktop Qt plugin now resolves this with - chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets - 150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same - scheduler via a thin model. Order of preference stays (1) share/save file + - paste text; (2) chunked QR streams through `BalQrTransferModel`. -- Styling minimal, follow existing QML components' look (reuse - `controls/` from Electrum where importable — prefer copying tiny primitives - to avoid coupling to upstream churn; decide during implementation). -- Every string wrapped with `electrum.i18n._`. - -Exit criterion: full manual walkthrough on desktop QML GUI (offscreen + -interactive) performing BOTH: (a) online profile — configure heirs → select -executors → build → sign → broadcast → invalidate; and (b) offline signer -profile — export unsigned bundle from an online wallet, import into a second -offline wallet instance, sign, re-export, import signed bundle back into the -first wallet and verify signatures combined/status COMPLETE. Walkthrough (b) -must pass with networking disabled. - -### Phase 4 — Android integration - -- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to - `contrib/android/buildozer_qml.spec` in the Electrum fork. -- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)** - and default-enable BAL in the APK build (D3: enabled by default). -- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1 - approved). Fallback if T4 picks auto-open: window opens on wallet load. -- Rebuild APK; smoke-test on device/emulator: - install → BAL already enabled → open wallet → full MVP walkthrough, - including airplane-mode signing round trip (file/QR transfer between an - online desktop and the offline device — for emulator testing, "offline" = - network disabled via settings). -- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators — - already house style), background network on mobile (willexecutor timeouts), - APK size impact (bal is small; icons only), share-intent/file access - permissions for bundle import/export. - -Exit criterion: signed test APK passes the same walkthrough as Phase 3. - -### Phase 5 — Tests & CI hygiene - -- New tests following repo conventions (`def test_*` + `__main__` block): - - `tests/test_qml_models.py` — models over fake/controller-backed wallet - (offscreen, no network). - - `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle - from wallet A → import into wallet B (same seed, offline) → sign → - export signed → merge back into A; assert COMPLETE status and signature - counts. Must pass with no network. - - `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed - QML app object; `so` wiring correct; load/unload wallet lifecycle. - - Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3 - tests/smoke_test.py electrum.plugins.bal` still green (qt path intact). -- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW - violations) before any delivery ZIP. -- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml - offscreen, Android APK] × [4.7.2, 4.8.0] where applicable. - -### Phase 6 — Release plumbing & docs - -- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included - in deterministic zip (harmless on desktop; enables future extraction-based - loading). -- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status, - limitations, and how to enable (`-g qml` / APK toggle). -- `CHANGELOG.md`: numbered entry at END per house rules. -- Version bump + release handled by `make-release.sh` as usual (owner-driven). - ---- - -## 5. Risks & mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed | -| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop | -| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting | -| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI | -| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable | -| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 | -| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test | -| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` | -| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) | -| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework | - ---- - -## 6. Questions for the owner — ANSWERED (2026-08-25) - -1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's - `main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle. -2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.** - The phone holds the keys and acts as air-gapped signer: import unsigned - bundle → review → sign → export signed bundle back to the online machine. - The sign/import/export page is therefore the top priority of Phase 3, and - the round-trip test is the top priority of Phase 5. -3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK** - (toggle still available to disable). -4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get - back later. Not blocking: see risk R10 mitigation. - ---- - -## 7. Effort estimate - -| Phase | Rough size | -|-------|-----------| -| 0 spikes | ~half day (mostly reading + one throwaway branch) | -| 1 skeleton | ~300 lines Python | -| 2 models (incl. shared export/merge helpers) | ~600–800 lines Python | -| 3 views (offline signer page first) | ~900–1300 lines QML | -| 4 android | fork-side patches + build iteration (device-dependent) | -| 5 tests | ~500 lines | -| 6 release/docs | small | - -Overall: comparable to a medium feature, dominated by Phase 3 UI polish and -Phase 4 device iteration. - ---- - -## 8. Findings log (append-only) - -- **F12** — The airgap round trip already exists in the Qt GUI layer and can - be ported almost verbatim: `export_json_file()` (window.py:1605) exports - all will items as `{wid: WillItem.to_dict()}` JSON (marking them - `EXPORTED`); the import side goes through `merge_will()` - (window.py:1620), which carries operational statuses, combines partial - signatures via `tx.combine_with_other_psbt()` when txids match, substitutes - the tx otherwise, and recomputes validity locally without network. - Conclusion: no new transfer format is needed; the plan is to give this - logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML - all use one implementation. - -### Decisions - -- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by - the owner. -- **D2** — Android = offline signing device; sign/import/export flow has - top priority. -- **D3** — BAL enabled by default in the custom APK. -- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10). diff --git a/bal/cli/controller.py b/bal/cli/controller.py index dbd4e1c..6e96ac7 100644 --- a/bal/cli/controller.py +++ b/bal/cli/controller.py @@ -43,6 +43,7 @@ from ..core.checkalive import ( CheckAliveError, check_alive_expired, resolve_date_to_check, + resolve_guard_threshold, ) from ..core.heirs import Heirs, is_op_return_address from ..core.plugin_base import BalConfig, BalPlugin @@ -314,6 +315,28 @@ class BalController: executor. """ will = {} + # Drop stale wallet-LOCAL will placeholders (mirror of the GUI + # build_will) so their coins are available to this build. + Will.remove_stale_wallet_history( + self.wallet, self.plugin.HISTORY_LABEL.get() + ) + # A (re)build may have anticipated the delivery (shorter heir recipes) + # while ``date_to_check`` is still anchored to the OLD built will. + # Recompute it for the will being built (earliest future delivery among + # the CURRENT heirs), mirroring ``BalWindow.build_will``, so the + # anticipated dates pass the build filter. + _new_locktime = min( + ( + Util.parse_locktime_string(h[2]) + for h in self.heirs.values() + ), + default=None, + ) + if _new_locktime: + self.date_to_check = resolve_date_to_check( + self.plugin.is_basic_mode(), self.will_settings, + built_locktime=_new_locktime, + ) self.willexecutors = Willexecutors.get_willexecutors( self.plugin, update=False, task=False ) @@ -434,7 +457,13 @@ class BalController: raise _user_facing(e) from e locktime = Util.parse_locktime_string(self.will_settings["locktime"]) - if locktime < date_to_check: + threshold_ts = resolve_guard_threshold( + self.plugin.is_basic_mode(), self.will_settings + ) + if threshold_ts is not None: + if locktime < threshold_ts: + raise UserFacingException(_("locktime is lower than threshold")) + elif locktime < date_to_check: raise UserFacingException(_("locktime is lower than threshold")) if not self.no_willexecutor: diff --git a/bal/core/checkalive.py b/bal/core/checkalive.py index c7c3087..51f63c0 100644 --- a/bal/core/checkalive.py +++ b/bal/core/checkalive.py @@ -96,6 +96,54 @@ def resolve_date_to_check( return threshold.to_timestamp() +def resolve_guard_threshold( + is_basic_mode: bool, + will_settings: Any, + now: float | None = None, +) -> float | None: + """Resolve the "locktime is lower than threshold" guard's reference. + + The guard compares the stored settings on ONE reference frame: the + delivery (``locktime``, kept as at the call site) against this threshold. + + Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the + built will's frozen tx locktime so that an unchanged will never reads as + expired -- this helper resolves the threshold from the **stored settings + alone**. Otherwise, when the stored relative locktime is shorter than the + frozen locktime of an old (still valid) built will (e.g. the delivery was + shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh + "1y" locktime against the old will's anchored threshold and wrongly fire, + even though locktime > threshold by the settings themselves. + + * BASIC mode: no threshold exists. Returns ``None`` and the caller falls + back to comparing the locktime against ``date_to_check`` (= now), so its + behaviour is unchanged. + * ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold + as-is. + * ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning + "N days BEFORE the delivery"): the threshold is anchored to the locktime + resolved forward from *now* (the settings' own delivery reading, never a + built tx), keeping both sides of the comparison in the same reference + frame, as the settings widget displays it. + + Returns ``None`` when there is no threshold to enforce (BASIC mode or a + missing stored value). + """ + if is_basic_mode: + return None + threshold_raw = will_settings.get("threshold") + if threshold_raw is None: + return None + threshold = BalTimestamp(threshold_raw) + if threshold.unit is None: + return threshold.to_timestamp() + now_dt = ( + datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None + ) + locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt) + return threshold.to_date(locktime_dt, reverse=True).timestamp() + + def check_alive_expired( is_basic_mode: bool, date_to_check: float, now: float | None = None ) -> bool: diff --git a/bal/core/will.py b/bal/core/will.py index 1dcb137..71d7ff1 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -28,6 +28,7 @@ The status flags themselves (the source of truth) stay here; only the mapping from datetime import datetime, timezone +from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL from electrum.i18n import _ from electrum.logging import Logger, get_logger from electrum.transaction import ( @@ -847,6 +848,48 @@ class Will: except Exception as e: _logger.error(f"save_valid_transactions_to_history failed: {e}") + @staticmethod + def remove_stale_wallet_history(wallet, history_label): + """Delete wallet-LOCAL will transactions saved under ``history_label``. + + ``save_valid_transactions_to_history`` stores the not-yet-signed + inheritance txs into the wallet's local history; those local + placeholders nominally spend the coins they reference. When the will is + REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the + stale placeholders must be removed so the coins become available again + to the new build (see ``Util.get_available_utxos``). Only + wallet-local/future (non-broadcast) txs whose label matches the history + label template are removed; confirmed/broadcast history is never + touched. Returns the txids that were removed. + """ + if not wallet or not getattr(wallet, "adb", None): + return [] + removed = [] + for txid, label in Will._wallet_labels(wallet): + if not label or not Util._label_matches_history(label, history_label): + continue + try: + height = int(wallet.adb.get_tx_height(txid).height()) + except Exception: + continue + if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE): + continue + try: + wallet.adb.remove_transaction(txid) + removed.append(txid) + except Exception as e: + _logger.error(f"remove from history failed for {txid}: {e}") + continue + try: + wallet.set_label(txid, None) + except Exception as e: + _logger.error(f"set_label failed for {txid}: {e}") + try: + wallet.save_db() + except Exception as e: + _logger.error(f"save_db failed after history purge: {e}") + return removed + @staticmethod def _add_transaction_to_history(wallet, tx, txid): """Store *tx* into the wallet's local history via ``adb``. @@ -1213,9 +1256,9 @@ class Will: if Util.parse_locktime_string(heirs[h][2]) >= check_date: count_heirs += 1 - if h not in heirs_found: - _logger.debug(f"heir: {h} not found") - raise HeirNotFoundException(h) + if h not in heirs_found: + _logger.debug(f"heir: {h} not found") + raise HeirNotFoundException(h) if not count_heirs: raise NoHeirsException("there are not valid heirs") if self_willexecutor and no_willexecutor == 0: diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index caafbea..84869f7 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -1135,13 +1135,14 @@ class BalBuildWillDialog(BalDialog): desired behaviour, and that the date shown in the panel/wizard must reflect this anticipated date (so the calendar .ics also uses it). - RELATIVE dates are additionally normalised here: a relative value - ("30d"/"1y") is re-parsed against "now" on every check, so it drifts - away from the fixed transaction locktime and the postpone check would - wrongly ask to invalidate the will every day. The stored locktime is - therefore frozen to the built transactions' absolute locktime, and a - relative threshold is frozen to its "N days before the delivery" - absolute value. + RELATIVE recipes ("30d"/"1y") are PRESERVED: they are resolved against + the built will's frozen locktime on every check (via + ``Util.resolve_locktime_against_tx`` for the postpone detection and + ``resolve_date_to_check(..., built_locktime=...)`` for the reference + timestamp), so they no longer drift away from the built transactions + and never trigger the daily invalidate prompt. Freezing them to an + absolute timestamp here would silently erase the user's relative + choice from WILL_SETTINGS. We route the update through BalWindow.update_setting_widgets, which is the single place that (1) stores the value in WILL_SETTINGS, (2) @@ -1156,72 +1157,46 @@ class BalBuildWillDialog(BalDialog): return min_locktime = int(min_locktime) stored_locktime = self.bal_window.will_settings["locktime"] - # A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed - # against "now" on every check, so it drifts one day per day away from - # the fixed tx locktime and the postpone check would ALWAYS see a - # postpone -> the plugin asks to invalidate the will every day. It must - # therefore be normalised here to the frozen absolute locktime of the - # built transactions, even when it happens to parse to the same moment - # today. (Only an absolute stored value is comparable, see below.) + # A RELATIVE stored value ("30d"/"1y") is PRESERVED: it is resolved + # against the built transactions on every check (the post-build + # `resolve_date_to_check` anchoring and `resolve_locktime_against_tx` + # in the postpone detection), so it no longer drifts and must not be + # frozen to an absolute timestamp here. Only an ABSOLUTE stored value + # is compared with the built transactions (see below). is_relative_locktime = ( isinstance(stored_locktime, str) and stored_locktime[-1:].lower() in ("d", "y") ) - # Current stored delivery date, as a comparable UNIX timestamp. - try: - current = int(Util.parse_locktime_string(stored_locktime)) - except Exception: - # If the stored value cannot be parsed, fall back to syncing. - current = None - # A genuine user-chosen POSTPONE (a later absolute date) is never - # overwritten; anything else is synced to the built transactions. - was_anticipation = current is not None and min_locktime < current - if not is_relative_locktime and current is not None and not was_anticipation: - pass - else: - _logger.debug( - f"sync delivery date to built tx locktime: " - f"{current} -> {min_locktime}" - ) - # Remember that we anticipated the date, so the later sign prompt can - # explain WHY signing is needed (see on_success_phase1). A pure - # relative->absolute normalisation is NOT an anticipation. - if was_anticipation: - self._date_was_anticipated = True - # update_setting_widgets stores the value, persists it and refreshes - # the date widgets in all panels/wizard (the .ics calendar too). - self.bal_window.update_setting_widgets( - min_locktime, "locktime", update_all=True - ) - # Same moving-target problem for a relative "Check Alive" threshold: - # it means "N days BEFORE the delivery" (the settings widget resolves it - # as real_threshold = locktime - N days), so it is normalised to that - # absolute date, referenced against the now-absolute stored locktime. - threshold_raw = self.bal_window.will_settings.get("threshold") - if ( - isinstance(threshold_raw, str) - and threshold_raw[-1:].lower() in ("d", "y") - ): + if not is_relative_locktime: + # Current stored delivery date, as a comparable UNIX timestamp. try: - locktime_ts = int( - Util.parse_locktime_string( - self.bal_window.will_settings["locktime"] - ) - ) - real_threshold = int( - BalTimestamp(threshold_raw) - .to_date(locktime_ts, reverse=True) - .timestamp() - ) - except Exception as e: - _logger.error(f"sync threshold to absolute failed: {e}") - else: + current = int(Util.parse_locktime_string(stored_locktime)) + except Exception: + # If the stored value cannot be parsed, fall back to syncing. + current = None + # A genuine user-chosen POSTPONE (a later absolute date) is never + # overwritten; a genuine automatic ANTICIPATION (built earlier + # than stored) is synced to the built transactions. + was_anticipation = current is not None and min_locktime < current + if was_anticipation: _logger.debug( - f"sync threshold {threshold_raw} -> absolute {real_threshold}" + f"sync delivery date to built tx locktime: " + f"{current} -> {min_locktime}" ) + # Remember that we anticipated the date, so the later sign + # prompt can explain WHY signing is needed + # (see on_success_phase1). + self._date_was_anticipated = True + # update_setting_widgets stores the value, persists it and + # refreshes the date widgets in all panels/wizard (the .ics + # calendar too). self.bal_window.update_setting_widgets( - real_threshold, "threshold", update_all=True + min_locktime, "locktime", update_all=True ) + # A relative "Check Alive" threshold ("N days BEFORE the delivery") is + # also PRESERVED: it is anchored on every check by + # ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does + # not need to be frozen to an absolute date here. def on_accept(self): try: diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 24736f3..55c3784 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -23,6 +23,7 @@ from ...core.checkalive import ( CheckAliveError, check_alive_expired, resolve_date_to_check, + resolve_guard_threshold, ) from .common import ( OP_RETURN_PREFIX, @@ -466,6 +467,31 @@ class BalWindow: def build_will(self, ignore_duplicate=True, keep_original=True): _logger.debug("building will...") + # Drop stale wallet-LOCAL will placeholders saved by previous prepares + # so their coins are available to this build (see remove_stale...). + Will.remove_stale_wallet_history( + self.window.wallet, self.bal_plugin.HISTORY_LABEL.get() + ) + # A (re)build may have anticipated the delivery (shorter heir recipes) + # while ``date_to_check`` is still anchored to the OLD built will. Using + # that stale anchor as the build filter would block every future + # delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will + # that is being built: its locktime is the earliest future delivery + # among the CURRENT heirs. The checks of the EXISTING will keep their + # anchored ``date_to_check`` (set in init_class_variables). + _new_locktime = min( + ( + Util.parse_locktime_string(h[2]) + for h in self.heirs.values() + ), + default=None, + ) + if _new_locktime: + self.date_to_check = resolve_date_to_check( + self.bal_plugin.is_basic_mode(), + self.will_settings, + built_locktime=_new_locktime, + ) will = {} # willtodelete = [] # willtoappend = {} @@ -745,6 +771,27 @@ class BalWindow: raise e + def is_locktime_below_threshold(self) -> bool: + """True when the stored settings make the delivery earlier than the + Check Alive threshold (the "locktime is lower than threshold" guard). + + Compares the delivery against the settings-derived threshold on the + SAME reference frame (see ``resolve_guard_threshold``), never against + the built-will-anchored ``date_to_check``: anchoring the guard to an + old, longer built will would wrongly fire right after the delivery was + shortened. The anchored reference still governs the validity and + expiry checks, which is where ``date_to_check`` belongs. + In BASIC mode there is no threshold, so the locktime is checked against + ``date_to_check`` (= now) exactly as before. + """ + locktime = Util.parse_locktime_string(self.will_settings["locktime"]) + threshold_ts = resolve_guard_threshold( + self.bal_plugin.is_basic_mode(), self.will_settings + ) + if threshold_ts is not None: + return locktime < threshold_ts + return self.date_to_check is not None and locktime < self.date_to_check + def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): try: _logger.info( @@ -757,6 +804,11 @@ class BalWindow: if not self.heirs: _logger.warning("not heirs {}".format(self.heirs)) return + # Free the coins locked by stale wallet-LOCAL will placeholders + # BEFORE the amount/UTXO checks below (Step 1) see them. + Will.remove_stale_wallet_history( + self.window.wallet, self.bal_plugin.HISTORY_LABEL.get() + ) try: self.init_class_variables() Will.check_amounts( @@ -791,8 +843,7 @@ class BalWindow: ) ) return - locktime = Util.parse_locktime_string(self.will_settings["locktime"]) - if locktime < self.date_to_check: + if self.is_locktime_below_threshold(): self.show_error(_("locktime is lower than threshold")) return if not self.no_willexecutor: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c046a55 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +"""Shared pytest fixtures. + +Guards every test against cross-file network pollution: several karen7 +regtest modules historically flipped ``electrum.constants.net`` to regtest at +import time, which broke unrelated offline tests (e.g. the CLI controller +suite) run in the same pytest process. +""" + +import pytest +from electrum import constants + + +@pytest.fixture(autouse=True) +def _restore_network(): + """Snapshot ``constants.net`` before each test and restore it after.""" + prev = constants.net + yield + constants.net = prev diff --git a/tests/test_cli_controller_offline.py b/tests/test_cli_controller_offline.py index 1960611..f793aa6 100644 --- a/tests/test_cli_controller_offline.py +++ b/tests/test_cli_controller_offline.py @@ -18,6 +18,7 @@ import shutil import sys import tempfile import time +import unittest.mock as mock sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) @@ -25,6 +26,8 @@ from electrum.simple_config import SimpleConfig from electrum.util import UserFacingException from bal.cli.controller import BalController +from bal.core.heirs import Heirs +from bal.core.util import Util VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf" @@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates(): assert result["invalidation_tx"] == {"txid": None, "tx": None} +def test_build_will_reanchors_date_to_check_to_new_locktime(): + """CLI mirror of the GUI regression: ``build_will`` must re-anchor + ``date_to_check`` to the CURRENT heirs' earliest delivery before building, + so an anticipated (shortened) rebuild is not blocked by the old built-will + anchor (which would yield NO_FUTURE_DATE in ``get_transactions``). + """ + with Plugin() as plugin: + plugin.USER_TYPE.set("advanced") + plugin.NO_WILLEXECUTOR.set(True) + plugin.ENABLE_MULTIVERSE.set(True) + plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20}) + c = _make_controller(plugin) + c.no_willexecutor = True + c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"] + + # Simulate an old built will frozen at 2y: reload keeps its (stale) + # anchor, which would reject the anticipated "1y" delivery. + c.init_class_variables() + stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400 + c.date_to_check = stale_anchor + assert Util.parse_locktime_string("1y") < c.date_to_check + + with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt: + result = c.build_will() + + assert result == {} + # build_will re-anchored date_to_check to the new 1y delivery... + expected = Util.parse_locktime_string("1y") - 150 * 86400 + assert abs(c.date_to_check - expected) < 3600 + # ...and used THAT anchor as the build filter, not the stale 2y one. + assert gt.call_args.args[-1] == c.date_to_check + + # ------------------------------------------------------------------ # # runner # ------------------------------------------------------------------ # diff --git a/tests/test_core_checkalive.py b/tests/test_core_checkalive.py index 644767c..2e73e50 100644 --- a/tests/test_core_checkalive.py +++ b/tests/test_core_checkalive.py @@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above) CheckAliveError, check_alive_expired, resolve_date_to_check, + resolve_guard_threshold, ) # ------------------------------------------------------------------ # @@ -148,6 +149,76 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back(): assert abs(result - expected) < 1 +# ------------------------------------------------------------------ # +# resolve_guard_threshold +# ------------------------------------------------------------------ # + + +def test_guard_threshold_basic_mode_returns_none(): + fake_now = 1_800_000_000.0 + threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now) + assert threshold is None + + +def _guard_locktime(settings, fake_now): + """Reproduce the call-site locktime expression of the guard.""" + from bal.core.plugin_base import BalTimestamp + + return BalTimestamp(settings["locktime"]).to_timestamp(fake_now) + + +def test_guard_threshold_absolute(): + fake_now = 1_800_000_000.0 + locktime = fake_now + 90 * 86400 + threshold = locktime - 30 * 86400 + settings = {"locktime": locktime, "threshold": threshold} + assert resolve_guard_threshold(False, settings, now=fake_now) == threshold + + +def test_guard_threshold_relative_fresh_anchor(): + """A relative threshold must be anchored to the FRESH locktime so the + guard and the settings always share one reference frame. + + Regression for the false positive where a still-valid built will frozen at + a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently + stored shorter delivery ("1y"): the old guard compared the fresh "1y" + locktime against that anchored threshold and wrongly fired "locktime is + lower than threshold", even though the settings themselves are consistent + (locktime is 30d AFTER the threshold). + """ + fake_now = 1_800_000_000.0 + settings = {"locktime": "1y", "threshold": "30d"} + locktime = _guard_locktime(settings, fake_now) + threshold = resolve_guard_threshold(False, settings, now=fake_now) + assert threshold is not None + assert locktime > threshold # internally consistent: no fire + assert threshold > fake_now + # The helper takes no built anchor: a frozen "2y" built will must NOT + # contaminate the result, although resolve_date_to_check (the expiry + # reference) legitimately keeps using it. + frozen_two_years = locktime + 365 * 86400 + anchored = resolve_date_to_check( + False, settings, now=fake_now, built_locktime=frozen_two_years + ) + assert anchored > threshold # built anchor pushes date_to_check forward... + assert locktime < anchored # ...which is exactly what used to fire the bug + + +def test_guard_threshold_relative_locktime_absolute_threshold(): + fake_now = 1_800_000_000.0 + threshold = fake_now + 200 * 86400 + settings = {"locktime": "1y", "threshold": threshold} + assert resolve_guard_threshold(False, settings, now=fake_now) == threshold + # "1y" from now is later than the stored absolute threshold: allowed. + locktime = _guard_locktime(settings, fake_now) + assert locktime > threshold + + +def test_guard_threshold_missing_returns_none(): + fake_now = 1_800_000_000.0 + assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None + + # ------------------------------------------------------------------ # # check_alive_expired # ------------------------------------------------------------------ # diff --git a/tests/test_core_heirs.py b/tests/test_core_heirs.py index c292066..7fab3aa 100644 --- a/tests/test_core_heirs.py +++ b/tests/test_core_heirs.py @@ -36,6 +36,7 @@ from bal.core.heirs import ( is_op_return_address, validate_op_return_hex, ) +from bal.core.util import Util # ------------------------------------------------------------------ # # Constants @@ -167,6 +168,32 @@ def test_heirs_amount_to_float(): assert heirs.amount_to_float("notanumber") == 0.0 +def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs(): + """A relative heir must survive the amount filter when the build anchor + (``from_locktime``) is recalculated for the anticipated delivery. + + Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD + (longer) built will; an "1y" heir resolved before that anchor was excluded + by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the + anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the + "1y" heir is kept. + """ + wallet = FakeWallet() + heirs = Heirs(wallet) + heirs["carol"] = ["addr1", "100%", "1y"] + + # Stale anchor (old built 2y will still frozen): "1y" is in the past + # relative to it -> excluded from the amount calculation. + stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400 + _, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500) + assert "carol" not in percent_heirs + + # Recalculated anchor for the new (1y) delivery: the heir is retained. + new_anchor = Util.parse_locktime_string("1y") - 150 * 86400 + _, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500) + assert "carol" in percent_heirs + + # ------------------------------------------------------------------ # # Validation (static methods) # ------------------------------------------------------------------ # diff --git a/tests/test_core_will.py b/tests/test_core_will.py index 94a7249..4f5c28d 100644 --- a/tests/test_core_will.py +++ b/tests/test_core_will.py @@ -13,8 +13,14 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +from bal.core.checkalive import resolve_date_to_check from bal.core.util import copy_structure -from bal.core.will import Will, WillItem +from bal.core.will import ( + HeirNotFoundException, + NoHeirsException, + Will, + WillItem, +) # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) _VALID_TX_HEX = ( @@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild(): assert raised, "adding an heir must raise HeirNotFoundException" +def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs(): + """Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose + ADVANCED check window is anchored to the frozen built delivery must trigger + a plain rebuild (HeirNotFoundException), NOT "No Heirs". + + Earlier the count gate resolved each current relative recipe from *now* + while ``check_date`` was anchored to the (longer) frozen built locktime, so + every heir fell below the window and was silently excluded -> NoHeirs even + though the will simply needs rebuilding on the new, shorter schedule.""" + lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build) + will_heirs = {"alice": ["addr_alice", 5000, "2y"]} + current_heirs = {"alice": ["addr_alice", 5000, "1y"]} + will = _make_will_with_heirs(will_heirs, lt) + will["willid_1"].set_status("COMPLETE", True) + check_date = resolve_date_to_check( + False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt + ) + assert check_date < lt # the anchored window really precedes the delivery + raised = None + try: + Will.check_willexecutors_and_heirs( + will, copy_structure(current_heirs), {}, False, check_date, 100 + ) + except HeirNotFoundException: + raised = "rebuild" + except NoHeirsException: + raised = "noheirs" + assert raised == "rebuild", ( + f"shortened recipe on a signed will must rebuild, got {raised!r}" + ) + + +def test_all_heirs_past_check_date_still_noheirs(): + """The "no valid heirs" gate is preserved: when every heir is coherent with + the built will but its delivery lies before ``check_date``, the check still + reports NoHeirsException (there is literally nothing future to inherit).""" + lt = 1_900_000_000 + will_heirs = {"alice": ["addr_alice", 5000, str(lt)]} + will = _make_will_with_heirs(will_heirs, lt) + raised = None + try: + Will.check_willexecutors_and_heirs( + will, copy_structure(will_heirs), {}, False, lt + 86400, 100 + ) + except HeirNotFoundException: + raised = "rebuild" + except NoHeirsException: + raised = "noheirs" + assert raised == "noheirs", ( + f"a fully delivered will must report NoHeirs, got {raised!r}" + ) + + def test_needs_server_check(): """Check button selection logic: only a VALID, PUSHED will with a will-executor that is not yet CHECKED must be queried on the server. diff --git a/tests/test_core_will_extra.py b/tests/test_core_will_extra.py index 19f9234..eb3d1cd 100644 --- a/tests/test_core_will_extra.py +++ b/tests/test_core_will_extra.py @@ -450,6 +450,12 @@ class FakeADB: def remove_transaction(self, txid): self.removed.append(txid) + # Simulate the real adb: dropping a stored tx frees the outputs it spent. + for utxos in self.outputs.values(): + for utxo in utxos.values(): + if getattr(utxo, "spent_txid", None) == txid: + utxo.spent_txid = None + utxo.spent_height = None def get_spender(self, outpoint): txid = self.spenders.get(outpoint) @@ -837,6 +843,73 @@ def test_get_available_utxos_none_locktime_is_raw_view(): assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == [] +# ------------------------------------------------------------------ # +# Will.remove_stale_wallet_history (pre-build history purge) +# ------------------------------------------------------------------ # + +def test_remove_stale_wallet_history_frees_equal_locktime_spend(): + # The stale placeholders (saved by a previous prepare) have the SAME + # locktime as the will being rebuilt, so get_available_utxos does NOT + # restore their coins (see test_...does_not_restore_not_later_locktime). + # The pre-build purge deletes them and the coins become available again. + wallet, utxo = _wallet_with_local_spend(locktime=1000) + spender = "ab" * 32 + assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == [] + removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) + assert removed == [spender] + assert wallet.adb.removed == [spender] + assert spender not in wallet.labels + assert [ + u.prevout.to_str() + for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) + ] == [utxo.prevout.to_str()] + + +def test_remove_stale_wallet_history_keeps_confirmed_spender(): + # A broadcast (confirmed) BAL-labelled tx is never purged. + addr = "bcrt1qexample" + spender = "ab" * 32 + utxo = _make_utxo(spent_txid=spender, spent_height=100) + wallet = FakeWallet( + stored_txs={spender: _make_multisig_ptx(0, locktime=2000)}, + heights={spender: 100}, + outputs={addr: {utxo.prevout.to_str(): utxo}}, + addresses=[addr], + ) + wallet.labels[spender] = _HISTORY_LABEL + removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) + assert removed == [] + assert wallet.adb.removed == [] + assert wallet.labels[spender] == _HISTORY_LABEL + + +def test_remove_stale_wallet_history_keeps_unlabeled_local_spender(): + # Wallet-local BAL-status tx without a matching history label stays. + wallet, _ = _wallet_with_local_spend(locktime=1000) + spender = "ab" * 32 + wallet.labels[spender] = "some other label" + removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) + assert removed == [] + assert wallet.adb.removed == [] + assert wallet.labels[spender] == "some other label" + + +def test_remove_stale_wallet_history_noop_without_wallet_or_adb(): + assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == [] + wallet = FakeWallet() + wallet.adb = None + assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == [] + + +def test_remove_stale_wallet_history_never_raises(): + adb = MagicMock() + adb.get_tx_height.side_effect = RuntimeError("boom") + wallet = MagicMock() + wallet.adb = adb + wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL} + assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == [] + + # ------------------------------------------------------------------ # # Main # ------------------------------------------------------------------ # diff --git a/tests/test_gui_prepare_will_history.py b/tests/test_gui_prepare_will_history.py index 6bf527a..bdf5e77 100644 --- a/tests/test_gui_prepare_will_history.py +++ b/tests/test_gui_prepare_will_history.py @@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh(): win.date_to_check = 1_800_000_000 win.will_settings = {"baltx_fees": 1, "locktime": "1 month"} win.bal_plugin = _CfgBag( + is_basic_mode=lambda: False, MAX_WILLEXECUTOR_FEE=_Cfg(1), SAVE_HISTORY=_Cfg(True), HISTORY_LABEL=_Cfg("LBL"), @@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh(): schedule_mock.assert_called_once_with() +def test_rebuild_purges_stale_wallet_history_before_building(): + # The rebuild path must drop stale wallet-LOCAL will placeholders (saved by + # an earlier prepare) so their coins are available to the new build. + win = object.__new__(BalWindow) + win.disable_plugin = False + win.heirs = {"h": object()} + win.willexecutors = {} + win.no_willexecutor = True + win.willitems = {} + win.will = {} + win.date_to_check = 1_800_000_000 + win.will_settings = {"baltx_fees": 1, "locktime": "1 month"} + win.bal_plugin = _CfgBag( + is_basic_mode=lambda: False, + MAX_WILLEXECUTOR_FEE=_Cfg(1), + SAVE_HISTORY=_Cfg(True), + HISTORY_LABEL=_Cfg("LBL"), + ) + win.window = _FakeWindow() + win.window.wallet = _Wallet() + with ( + patch.object(Util, "get_available_utxos", return_value=[]), + patch.object(Util, "parse_locktime_string", return_value=1_800_000_001), + patch.object(Will, "get_min_locktime", return_value=0), + patch.object(Will, "check_amounts"), + patch.object(BalWindow, "init_class_variables"), + patch.object(BalWindow, "build_will"), + patch.object( + BalWindow, + "check_will", + side_effect=[NotCompleteWillException(), None], + ), + patch.object(BalWindow, "update_all"), + patch.object(BalWindow, "_schedule_history_refresh"), + patch.object(Will, "remove_stale_wallet_history") as purge_mock, + ): + BalWindow.build_inheritance_transaction(win) + purge_mock.assert_called_once_with(win.window.wallet, "LBL") + + # ------------------------------------------------------------------ # # Main # ------------------------------------------------------------------ # diff --git a/tests/test_gui_will_flows.py b/tests/test_gui_will_flows.py index 02031d6..7faafd7 100644 --- a/tests/test_gui_will_flows.py +++ b/tests/test_gui_will_flows.py @@ -390,6 +390,110 @@ def test_insufficient_funds_warns(): assert not ctl.willitems +def test_guard_not_blocked_by_old_built_will(): + """Regression: shortening the delivery in the STORED settings (relative + "1y"/"30d") while an old, still-VALID built will is frozen at a longer + locktime must NOT fire the "locktime is lower than threshold" guard. + + The old guard compared the fresh settings locktime against ``date_to_check`` + anchored to the built will (see ``resolve_date_to_check``), so a built-will + delivery longer than the settings' one made it fire even though the settings + are internally consistent (locktime is 30d AFTER the threshold). The guard + must instead compare the stored settings on a single reference frame + (``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its + built anchor for the expiry/validity checks. + """ + with _no_willexecutors(): + ctl = make_controller() + ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode + ctl.prepare_will() + txid, item = _single(ctl) + + # Freeze the built (VALID) will at a delivery one year longer than the + # now-shortened settings: the pre-fix guard would reject the rebuild. + item.tx.locktime = item.tx.locktime + 365 * 86400 + ctl.will_settings = {"locktime": "1y", "threshold": "30d"} + Util.fix_will_settings_tx_fees(ctl.will_settings) + + ctl.init_class_variables() + + # date_to_check is anchored to the built will (long delivery)... + assert ctl.date_to_check == item.tx.locktime - 30 * 86400 + # ...and the OLD guard would have fired here: + old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"]) + assert old_locktime < ctl.date_to_check + # but the settings themselves are consistent, so the guard must pass: + assert ctl.is_locktime_below_threshold() is False + assert not ctl.window.errors + + +def test_anticipated_rebuild_reanchors_date_to_check(): + """Regression (karen7): rebuilding a SIGNED will whose delivery was + anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must + succeed. + + ``date_to_check`` stays anchored to the OLD built delivery for the validity + checks, but ``build_will`` must re-anchor it to the NEW (earliest current) + delivery as its build filter: before the fix the stale 2028 anchor rejected + every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build + reported ``NO_FUTURE_DATE``. The old signed item is then superseded by + ``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will + is coherent again. + """ + with _no_willexecutors(): + ctl = make_controller() + ctl.bal_plugin.USER_TYPE.set("advanced") + # Per-heir deliveries require multiverse mode (the only way heirs can + # carry a different recipe than the settings locktime). + ctl.bal_plugin.ENABLE_MULTIVERSE.set(True) + ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20} + Util.fix_will_settings_tx_fees(ctl.will_settings) + ctl.heirs["alice"][2] = "2y" + ctl.heirs["bob"][2] = "2y" + + # Build and sign a 2y will (the old, committed delivery). + ctl.prepare_will() + old_txid, _old_item = _single(ctl) + old_locktime = _old_item.tx.locktime + signed = ctl.sign_transactions(None) + _old_item.tx = Will.get_tx_from_any(str(signed[old_txid])) + Will.check_signatures(ctl.willitems, ctl.wallet) + assert _old_item.get_status("COMPLETE") + + # Anticipate: shorten every heir to 1y. + ctl.heirs["alice"][2] = "1y" + ctl.heirs["bob"][2] = "1y" + + ctl.init_class_variables() + # date_to_check stays anchored to the OLD built delivery... + assert ctl.date_to_check == old_locktime - 150 * 86400 + # ...and that stale anchor would reject the anticipated "1y" dates. + assert Util.parse_locktime_string("1y") < ctl.date_to_check + + # The rebuild must succeed (re-anchored to the new delivery). + willitems = ctl.build_inheritance_transaction() + + assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire" + new_valid = [ + it for tid, it in willitems.items() + if tid != old_txid and it.get_status("VALID") + ] + assert new_valid, "the anticipated (1y) will must build and stay VALID" + new_item = new_valid[0] + assert new_item.tx.locktime < old_locktime, "delivery must be anticipated" + # date_to_check was re-anchored to the rebuilt delivery (1y minus 150d). + assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600 + + # The old signed item is kept but superseded (REPLACED -> not VALID). + assert _old_item.get_status("REPLACED") is True + assert _old_item.get_status("VALID") is False + + # The rebuilt will is coherent (plain rebuild, no on-chain invalidation). + assert ctl.check_will() is True + assert not any("delivery date" in m for m in ctl.window.messages) + assert not ctl.window.errors + + def _run_all(): tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")] for fn in tests: diff --git a/tests/test_heir_relative_anchor.py b/tests/test_heir_relative_anchor.py index 4b36660..92d57b1 100644 --- a/tests/test_heir_relative_anchor.py +++ b/tests/test_heir_relative_anchor.py @@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here: never read as EXPIRED because the check window drifts past the frozen tx locktime. -The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact -reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen -tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings +The reported state (reproduced hermetically here — the original live wallet +dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is: +heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is +2027-08-05 (built 2026-08-05), and will_settings ``{"locktime": "2y", "threshold": "150d"}``. Run: @@ -28,16 +29,14 @@ Run: python3 tests/test_heir_relative_anchor.py """ -import json import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +import pytest # noqa: E402 (path insert above) 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 @@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402 WillPostponedException, ) + +@pytest.fixture(autouse=True) +def _regtest_net(): + """Run these regtest-focused tests with BitcoinRegtest, restoring mainnet + afterwards so sibling test modules are unaffected by the net switch.""" + constants.net = constants.BitcoinRegtest + yield + constants.net = constants.BitcoinMainnet + + # A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0; # the tests override ``tx.locktime`` to simulate the frozen signed locktime. _VALID_TX_HEX = ( @@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected(): # ------------------------------------------------------------------ # -# karen7 wallet regression (real fixture) +# karen7 regression (hermetic, no live wallet fixture) # ------------------------------------------------------------------ # - -def _load_karen7(): - path = os.path.join(os.path.dirname(__file__), "karen7") - with open(path) as f: - return json.load(f) +# karen7's reported state, reproduced hermetically: heirs "1y", a signed item +# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a +# relative "150d" delivery window and a "2y" promised locktime. +_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"} def test_karen7_frozen_delivery_not_expired(): """ADVANCED date_to_check anchored to the frozen tx locktime: the check window opens BEFORE the delivery, so the will is never read as expired.""" - data = _load_karen7() - will_settings = data["will_settings"] - valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" - wi = WillItem(data["will"][valid_wid], _id=valid_wid) - built_locktime = Will.get_min_locktime({valid_wid: wi}) + heirs = {"alice": ["addr_alice", 5000, "1y"]} + item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True) + will = {"willid_1": item} + built_locktime = Will.get_min_locktime(will) assert built_locktime is not None - assert built_locktime == int(wi.tx.locktime) + assert built_locktime == int(item.tx.locktime) date_to_check = resolve_date_to_check( - False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime + False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=built_locktime ) assert int(date_to_check) < built_locktime # Re-evaluated 10 days later the window is identical (no daily drift). later = resolve_date_to_check( - False, will_settings, now=1_800_000_000.0 + 10 * 86400, + False, _WILL_SETTINGS, now=1_800_000_000.0 + 10 * 86400, built_locktime=built_locktime, ) assert date_to_check == later def test_karen7_unchanged_heirs_are_coherent(): - """The karen7 heirs (unchanged relative "2d") are coherent with the frozen - signed tx: the plugin must NOT ask to invalidate the will.""" - data = _load_karen7() - valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" + """Unchanged relative "1y" heirs are coherent with the frozen signed tx: + the plugin must NOT ask to invalidate the will.""" + heirs = {"alice": ["addr_alice", 5000, "1y"]} # Use _FROZEN (a UTC-midnight value) so the check is compatible with # the UTC anchoring code. frozen_locktime = _FROZEN date_to_check = resolve_date_to_check( - False, data["will_settings"], + False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=frozen_locktime, ) outcome = _run_heir_check( - data["will"][valid_wid]["heirs"], - data["heirs"], + copy_structure(heirs), + copy_structure(heirs), frozen_locktime, status_complete=True, ) @@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent(): # ------------------------------------------------------------------ # if __name__ == "__main__": + constants.net = constants.BitcoinRegtest for name in sorted(dir()): if name.startswith("test_"): globals()[name]() diff --git a/tests/test_sync_locktime_built_txs.py b/tests/test_sync_locktime_built_txs.py index f969f91..aa62204 100644 --- a/tests/test_sync_locktime_built_txs.py +++ b/tests/test_sync_locktime_built_txs.py @@ -2,13 +2,17 @@ Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``. This is the post-build sync that keeps the plugin's stored delivery date -(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the -BUILT transactions' fixed locktime. The bug it fixes (reported by the owner): +(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed +locktime when the core AUTOMATICALLY anticipates it (one day earlier than +stored). - ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin - asks to invalidate the will EVERY DAY. The relative value is re-parsed - against "now" on every check, so it drifts one day per day away from the - fixed tx locktime and the postpone check always sees a "postpone". +RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem +that once forced freezing them to absolute timestamps is solved at the root by +anchoring every relative recipe against the built transactions +(``Util.resolve_locktime_against_tx`` for the postpone detection, +``resolve_date_to_check(..., built_locktime=...)`` for the reference +timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date +moves the stored value. The method is exercised with a lightweight fake ``self`` (no Qt event loop, no Electrum wallet) by calling it as an unbound method. @@ -24,7 +28,6 @@ from types import SimpleNamespace sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) -from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above) from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above) # ------------------------------------------------------------------ # @@ -68,34 +71,30 @@ def _call_sync(will_settings, tx_locktimes, recorded): # Tests # ------------------------------------------------------------------ # -def test_relative_locktime_normalized_to_absolute(): - """The reported bug: a relative stored locktime is frozen to the absolute - value of the built transaction, even when it parses to the same moment.""" +def test_relative_locktime_preserved(): + """A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild: + it is anchored against the built transactions on every check, so it must + not be frozen to an absolute timestamp in WILL_SETTINGS.""" tx_locktime = 1_800_000_000 recorded = [] fake = _call_sync( {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded ) - assert fake.bal_window.will_settings["locktime"] == tx_locktime - assert fake.bal_window.will_settings["locktime"] != "90d" - # A pure relative->absolute normalisation is NOT an anticipation: the sign - # prompt must not claim the date was anticipated. + assert fake.bal_window.will_settings["locktime"] == "90d" + assert fake.bal_window.will_settings["threshold"] == "30d" + assert recorded == [], "a relative recipe must never be rewritten" assert fake._date_was_anticipated is False -def test_relative_threshold_frozen_to_absolute(): - """A relative threshold ("N days BEFORE the delivery") is normalised to the - same absolute value the settings widget computes (real_threshold).""" +def test_relative_threshold_preserved(): + """Same for the relative "Check Alive" threshold: it stays relative.""" tx_locktime = 1_800_000_000 recorded = [] fake = _call_sync( {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded ) - expected = int( - BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp() - ) - assert fake.bal_window.will_settings["threshold"] == expected - assert ("threshold", expected, True) in recorded + assert fake.bal_window.will_settings["threshold"] == "30d" + assert recorded == [] def test_absolute_locktime_unchanged_on_equal(): @@ -113,8 +112,8 @@ def test_absolute_locktime_unchanged_on_equal(): def test_anticipation_sets_flag_and_moves_earlier(): - """A real anticipation (built locktime earlier than the stored absolute - one) still moves the date earlier and flags the sign prompt.""" + """A real automatic anticipation of an ABSOLUTE stored date (built earlier + than stored) still moves the date earlier and flags the sign prompt.""" tx_locktime = 1_700_000_000 recorded = [] fake = _call_sync( @@ -129,7 +128,7 @@ def test_anticipation_sets_flag_and_moves_earlier(): def test_stored_earlier_than_built_never_moved_later(): """A stored absolute date that is already EARLIER than the built txs (the user moved the delivery later) is never pulled back up on rebuild: only - anticipation (built < stored) and relative normalisation move the value.""" + anticipation (built < stored) moves the value.""" stored = 1_800_000_000 recorded = [] fake = _call_sync( @@ -142,39 +141,39 @@ def test_stored_earlier_than_built_never_moved_later(): def test_multiple_txs_uses_minimum_locktime(): - """When several transactions carry different locktimes, the minimum is used - (owner-confirmed behaviour for the delivery date shown in the UI).""" + """When several ABSOLUTE transactions carry different locktimes, the minimum + is used for a genuine automatic anticipation (owner-confirmed behaviour for + the delivery date shown in the UI).""" min_locktime = 1_750_000_000 recorded = [] fake = _call_sync( - {"locktime": "90d", "threshold": "30d"}, + {"locktime": 1_800_000_000, "threshold": 1_600_000_000}, [min_locktime, min_locktime + 86_400], recorded, ) assert fake.bal_window.will_settings["locktime"] == min_locktime -def test_relative_locktime_stops_daily_postpone(): - """End-to-end guard for the reported bug: after the sync, re-parsing the - stored (now absolute) locktime on later days always equals the built - tx locktime, so the postpone check never fires again.""" - from datetime import datetime, timedelta +def test_relative_locktime_stays_coherent_via_anchor(): + """Daily-drift guard: an UNCHANGED relative recipe is resolved against the + tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT + being frozen to an absolute value it still reads as COHERENT (== tx + locktime) on later days - the postpone check never fires again.""" + from datetime import datetime, timedelta, timezone from bal.core.util import Util - tx_locktime = 1_800_000_000 - recorded = [] - fake = _call_sync( - {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded - ) - stored = fake.bal_window.will_settings["locktime"] + # resolve_locktime_against_tx normalises to UTC midnight before anchoring, + # so use a midnight-UTC frozen tx locktime (the timestamp the engine itself + # stores after building). + tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp()) + built = "90d" # recipe frozen at build time + current = "90d" # unchanged recipe today for _day in range(0, 7): - # Simulate the check on later days: parse the STORED value (which is - # now the absolute tx locktime) and compare with the fixed tx locktime. - new_locktime = Util.parse_locktime_string(stored) - assert new_locktime == tx_locktime - assert new_locktime <= tx_locktime # no POSTPONE / drift - # Sanity: a RELATIVE value would have drifted past it (the bug). + resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime) + assert resolved == tx_locktime # no POSTPONE / drift + # Sanity: a naive forward-from-now re-parse would have drifted past it + # (the bug the anchor fixes). drifted = int( ( datetime.fromtimestamp(tx_locktime) + timedelta(days=1)