Compare commits
14 Commits
c80f0e1524
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
| deeec042d6 | |||
| 085d39a2a5 | |||
| 539e32e837 | |||
| 9c4697c923 | |||
| 42f05d3c4f | |||
| 1da724b591 | |||
| 8647eee586 | |||
| 3a9ee5adb9 | |||
| 2c9f6bdd9d | |||
| 4dfb3fc41c | |||
| 1ae1617172 | |||
| 125bf09b9a | |||
| b5752a42f0 | |||
| ce659048ca |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ tmp*
|
||||
|
||||
# Release artifacts
|
||||
bal_v*.zip.*
|
||||
tests/karen7
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@@ -20,8 +20,9 @@ The plugin's `bal/` directory is symlinked into
|
||||
|
||||
## Test & verify
|
||||
|
||||
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
|
||||
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||
Tests work **both** as standalone scripts and via pytest (tests use `def test_*`
|
||||
naming and also have `if __name__ == "__main__"` blocks). Run a single file
|
||||
directly:
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
@@ -29,6 +30,13 @@ python3 tests/test_core_heirs.py # core, no Qt needed
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
|
||||
```
|
||||
|
||||
Or run a batch with pytest (as `make-release.sh` does):
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
|
||||
```
|
||||
|
||||
- Most core tests run offline (no wallet/network). Some files
|
||||
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
|
||||
exercise will-executor/network flows and need the live servers — don't rely on
|
||||
@@ -43,7 +51,8 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of
|
||||
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
|
||||
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
|
||||
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
|
||||
E501 ignored).
|
||||
E501 ignored). Per-file ignores suppress `F403`/`F405` for the intentional
|
||||
`from .common import *` hub pattern in `bal/gui/qt/`.
|
||||
- Lint via the repo venv: `./venv/bin/ruff`
|
||||
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
|
||||
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
|
||||
@@ -53,9 +62,18 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of
|
||||
## Architecture
|
||||
|
||||
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
|
||||
`plugin_base.py`, `util.py`). Must never import Qt.
|
||||
`plugin_base.py`, `util.py`, `checkalive.py`, `reminders.py`,
|
||||
`input_rules.py`).
|
||||
Must never import Qt.
|
||||
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
|
||||
`plugin.py` is the Electrum `@hooks` entry, `qt.py` is a zipimport shim.
|
||||
`plugin.py` is the Electrum `@hooks` entry. `qt.py` is a zipimport shim.
|
||||
`common.py` uses `import *` intentionally (ruff suppresses F403/F405 here);
|
||||
`bal/gui/qt/*.py` all import from it.
|
||||
- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon
|
||||
entry point, `commands.py` registers `bal_*` commands with Electrum.
|
||||
- `bal/wallet_util/` = wallet helper utilities for Qt and core.
|
||||
- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers
|
||||
via `manifest.json`; they import the real `Plugin` class via `importlib`.
|
||||
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
|
||||
`make-release.sh`).
|
||||
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
|
||||
|
||||
170
AUDIO_MODEM_DEBIAN.md
Normal file
170
AUDIO_MODEM_DEBIAN.md
Normal file
@@ -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 "$BAL_HOME/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 "$BAL_HOME/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()/" \
|
||||
"$BAL_HOME/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 "$BAL_HOME/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 "$BAL_HOME/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
|
||||
```
|
||||
697
CHANGELOG.md
697
CHANGELOG.md
@@ -2568,3 +2568,700 @@ of a session, without requiring the normal wizard flow to have run first.
|
||||
`test_merge_will_validity_error_logs_without_crashing`.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 49. OP_RETURN support for heirs
|
||||
|
||||
**Date:** 2026-07-30
|
||||
|
||||
**Goal:** allow heirs to produce an OP_RETURN output instead of a regular BTC
|
||||
payment. An address prefixed with `OP_RETURN:` carries hex data (max 80 bytes);
|
||||
such heirs always have amount 0 and are excluded from the normal amount
|
||||
calculations (percentage normalization, dust checks, leftover redistribution).
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/heirs.py`
|
||||
- `validate_heir`: new `OP_RETURN:<hex>` address validation (rejects non-hex,
|
||||
> 80 bytes). OP_RETURN heirs are stored with amount `"0"` and carry the raw
|
||||
hex data in the address field.
|
||||
- `prepare_lists`: OP_RETURN heirs are skipped during amount calculation
|
||||
(`normalize_perc`, dust checks). They are kept in the will but produce a
|
||||
zero-value output.
|
||||
- `buildTransactions`: when building the transaction, OP_RETURN heirs emit a
|
||||
`OP_RETURN <hex>` scriptPubKey output with value 0, matching Bitcoin's
|
||||
OP_RETURN output standard.
|
||||
- `get_transactions`: OP_RETURN heirs are grouped with their locktime peers
|
||||
but excluded from fee/dust arithmetic.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- Heir dialog / wizard: the address field accepts `OP_RETURN:` prefix and
|
||||
shows a decoded-text message field when an OP_RETURN address is entered.
|
||||
- `build_will` / `_build_success_report`: OP_RETURN heirs display the
|
||||
decoded message text in the build report instead of a BTC address.
|
||||
|
||||
- `bal/gui/qt/lists.py`
|
||||
- Heir list: OP_RETURN heirs display the decoded message in the address
|
||||
column and show "0" for the amount.
|
||||
|
||||
- `bal/gui/qt/common.py`
|
||||
- Re-export the new `validate_op_return_hex` helper for the GUI layer.
|
||||
|
||||
- `tests/test_core_heirs.py`
|
||||
- 8 new tests: OP_RETURN validation (valid hex, too long, non-hex), OP_RETURN
|
||||
heirs excluded from amount calculations, OP_RETURN output shape in built
|
||||
transactions, mixed OP_RETURN + regular heirs.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed production files: no new errors.
|
||||
- Full test suite: 307 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 50. Core extraction: GUI-free logic into bal/core/ (reminders, checkalive, input_rules); RLock pickle fix
|
||||
|
||||
**Date:** 2026-08-05
|
||||
|
||||
**Goal:** extract GUI-free business logic that was previously embedded in Qt
|
||||
widgets (`bal/gui/qt/widgets.py`) into standalone `bal/core/` modules, making
|
||||
them independently testable without Qt. Also fix a critical `copy.deepcopy(tx)`
|
||||
crash on Electrum 4.8 (`RLock` cannot be pickled) and improve wallet-DB
|
||||
persistence robustness.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/checkalive.py` (new)
|
||||
- `resolve_date_to_check`: computes the effective check-alive timestamp from
|
||||
will-settings and user type (BASIC uses `now()`; ADVANCED uses the stored
|
||||
threshold). Extracted from `window.py init_class_variables`.
|
||||
- `check_alive_expired`: pure-logic test for whether the check-alive has
|
||||
passed. Previously duplicated inline in `window.py`.
|
||||
|
||||
- `bal/core/reminders.py` (new)
|
||||
- `compute_reminder_offsets`, `basic_reminder_offsets`, `BALCalendar.write_ics`,
|
||||
`BALCalendar._ics_provider`: all calendar/reminder logic extracted from
|
||||
`widgets.py`. Generates iCal (.ics) files with separate VEVENT entries per
|
||||
reminder date. No Qt dependency.
|
||||
|
||||
- `bal/core/input_rules.py` (new)
|
||||
- `LockTimeRawEdit`, `LockTimeDateEdit`, `BalTimeEditWidget`,
|
||||
`ThresholdTimeWidget`: GUI-free data models for locktime/threshold
|
||||
validation and the Raw/Date selector logic. The Qt widgets in
|
||||
`widgets.py` now thin-wrap these helpers.
|
||||
|
||||
- `bal/core/heirs.py`
|
||||
- Fixed `copy.deepcopy(tx)` failure on Electrum 4.8: the `Transaction`
|
||||
object contains a `_thread.RLock` that cannot be pickled. Will-item
|
||||
persistence now re-parses the transaction from its hex serialization
|
||||
instead of deep-copying.
|
||||
- Invalid heirs (sentinel values from failed builds) are now kept in the
|
||||
wallet DB instead of being silently dropped, so the user can see and
|
||||
correct them.
|
||||
|
||||
- `bal/core/plugin_base.py`
|
||||
- Minor adjustments to support the extracted modules.
|
||||
|
||||
- `bal/gui/qt/widgets.py`
|
||||
- Major slim-down: business logic delegates to `bal/core/checkalive.py`,
|
||||
`bal/core/reminders.py`, and `bal/core/input_rules.py`. Only Qt widget
|
||||
creation and layout remain.
|
||||
|
||||
- `bal/gui/qt/calendar.py`
|
||||
- Adapted to use `bal/core/reminders.py` for .ics generation.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- `init_class_variables` now calls `resolve_date_to_check` from
|
||||
`bal/core/checkalive.py` instead of computing inline.
|
||||
|
||||
- `bal/gui/qt/common.py`
|
||||
- Updated re-exports for the new core modules.
|
||||
|
||||
- Tests reorganized: core-only tests moved to `tests/test_core_checkalive.py`,
|
||||
`tests/test_core_reminders.py`, `tests/test_core_input_rules.py` (run
|
||||
without Qt).
|
||||
|
||||
- `tests/karen7`: fixture file compressed/updated for the new test structure.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 388 passed (significant increase due to new core test modules).
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 51. "Rebuild will on wallet close" setting (`REBUILD_ON_CLOSE`)
|
||||
|
||||
**Date:** 2026-08-14
|
||||
|
||||
**Goal:** add a new plugin setting that lets the plugin rebuild the will
|
||||
automatically when Electrum closes, skipping the full Build wizard. When
|
||||
enabled, closing Electrum triggers a one-shot prepare/inheritance flow
|
||||
(check, rebuild if needed, sign, broadcast) without showing the
|
||||
`BalBuildWillDialog`.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/plugin_base.py`
|
||||
- New persisted config `REBUILD_ON_CLOSE = BalConfig(config,
|
||||
"bal_rebuild_on_close", False)` (default OFF), with explanatory comment.
|
||||
|
||||
- `bal/gui/qt/plugin.py`
|
||||
- New "Rebuild on close" checkbox in the settings dialog, bound to
|
||||
`REBUILD_ON_CLOSE`, with a tooltip explaining the behaviour. Added to the
|
||||
"Reset to Default Setting" list.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- `on_close`: when `REBUILD_ON_CLOSE` is enabled, the close flow runs
|
||||
the auto-rebuild path (check + rebuild + sign + push) instead of the
|
||||
full wizard dialog. The legacy wizard-on-close path is kept when the
|
||||
setting is OFF.
|
||||
|
||||
- `tests/test_rebuild_on_close_setting.py` (new)
|
||||
- 8 tests: default OFF, toggle/persist, close triggers rebuild when ON,
|
||||
close skips rebuild when OFF, reset restores default.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 396 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 52. Headless CLI layer (`bal/cli/`, `bal/cmdline.py`, `bal_*` daemon commands)
|
||||
|
||||
**Date:** 2026-08-14
|
||||
|
||||
**Goal:** expose the full BAL inheritance cycle via Electrum's command-line
|
||||
interface (daemon mode), without the Qt GUI. This enables scripting,
|
||||
automation, and headless server usage.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/cmdline.py` (new)
|
||||
- Zip-import shim for Electrum's `gui_name='cmdline'` plugin loader.
|
||||
- Follows the same `importlib` pattern as `qt.py` but never imports Qt.
|
||||
- Re-exports `Plugin` from `bal.cli.plugin`.
|
||||
|
||||
- `bal/cli/__init__.py` (new)
|
||||
- Registers the `bal_*` commands with Electrum on import.
|
||||
|
||||
- `bal/cli/plugin.py` (new)
|
||||
- `Plugin(BalPlugin)` -- minimal entry point for the daemon (no Qt hooks,
|
||||
no `bal_windows`).
|
||||
|
||||
- `bal/cli/commands.py` (new)
|
||||
- 30 `@plugin_command` async functions registered as `bal_*` commands:
|
||||
settings (list/get/set/reset), heirs (list/show/add/update/delete/import/
|
||||
export), will-executors (list/show/add/update/select/delete/ping/download/
|
||||
import/export), will (status/check/prepare/sign/broadcast/export/
|
||||
import_merge/invalidate/check_executor).
|
||||
- Each command is a thin transport layer: validates args, delegates to
|
||||
`BalController`, returns JSON-serializable results.
|
||||
|
||||
- `bal/cli/controller.py` (new, ~1100 lines)
|
||||
- `BalController` -- headless replica of `BalWindow`. Reads/writes wallet DB,
|
||||
config, and will-executors without any Qt dependency.
|
||||
- Methods mirror `BalWindow` flows: `load_willitems`, `save_willitems`,
|
||||
`init_class_variables`, `build_inheritance_transaction`, `sign_transactions`,
|
||||
`push_transactions_to_willexecutors`, `check_transactions`, `export_json_file`,
|
||||
`merge_will_from_file`, `invalidate_will`.
|
||||
- Domain exceptions (`WillExpiredException`, `HeirNotFoundException`, etc.)
|
||||
are converted to `UserFacingException` with clear text.
|
||||
|
||||
- `bal/manifest.json`
|
||||
- `"available_for"` updated from `["qt"]` to `["qt", "cmdline"]`.
|
||||
|
||||
- `bal/__init__.py`
|
||||
- Added `from .cli import commands` to register `bal_*` commands on import
|
||||
(both CLI pre-parse and GUI startup).
|
||||
|
||||
- `tests/test_cli_commands_registered.py` (new)
|
||||
- 4 tests: all `bal_*` commands registered, all are coroutines, no duplicate
|
||||
registration, all args documented.
|
||||
|
||||
- `tests/test_cli_controller_offline.py` (new)
|
||||
- Offline CRUD tests for heirs, will-executors, settings, and will
|
||||
import/export merge via `BalController` (no network).
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on new files: clean.
|
||||
- Full test suite: 427 passed.
|
||||
- `tests/test_cli_commands_registered.py`: all 4 tests pass.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 53. Auto-rebuild on new transactions (`AUTO_REBUILD`)
|
||||
|
||||
**Date:** 2026-08-16
|
||||
|
||||
**Goal:** when new transactions are detected in the wallet (e.g. incoming
|
||||
payments), automatically rebuild the will so the inheritance covers the
|
||||
new UTXOs. The delivery date is anticipated by one day to orphan the old
|
||||
will on-chain; an on-chain invalidation is only needed when the anticipated
|
||||
locktime crosses the Check Alive threshold.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/plugin_base.py`
|
||||
- New persisted config `AUTO_REBUILD = BalConfig(config,
|
||||
"bal_auto_rebuild", False)` (default OFF), with explanatory comment.
|
||||
|
||||
- `bal/gui/qt/plugin.py`
|
||||
- New "Auto-rebuild" checkbox in the settings dialog, bound to
|
||||
`AUTO_REBUILD`, with a tooltip. Added to the "Reset to Default Setting"
|
||||
list.
|
||||
|
||||
- `bal/gui/qt/window.py`
|
||||
- New `_auto_rebuild_on_new_tx()` method: triggered when Electrum detects
|
||||
a new transaction in the wallet. Runs the full prepare flow: check
|
||||
coherence, rebuild with the anticipated locktime (delivery date minus 1
|
||||
day), persist, sign (if passwordless), push to will-executors.
|
||||
- When the anticipated locktime crosses the Check Alive threshold, returns
|
||||
an invalidation transaction instead of auto-completing.
|
||||
- Connected to Electrum's `new_transaction` signal.
|
||||
|
||||
- `tests/test_auto_rebuild_on_new_tx.py` (new)
|
||||
- 16 tests: AUTO_REBUILD default OFF, toggle/persist, rebuild triggers on
|
||||
new tx, locktime anticipation by 1 day, threshold crossing returns
|
||||
invalidation, passwordless wallet signs automatically, encrypted wallet
|
||||
returns invalidation tx for manual signing.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 432 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 54. CLI `bal_will_autorebuild` command
|
||||
|
||||
**Date:** 2026-08-16
|
||||
|
||||
**Goal:** expose the auto-rebuild flow (entry #53) as a headless CLI command,
|
||||
so scripts and daemons can trigger the one-shot check/rebuild/sign/push
|
||||
cycle without the Qt GUI.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/cli/commands.py`
|
||||
- New `bal_will_autorebuild` async command (flag `nw`): runs the full
|
||||
auto-rebuild flow via `BalController.auto_rebuild()`. Returns a JSON
|
||||
object with `result` (`valid`, `no_heirs`, `invalidated`, `nothing`,
|
||||
`needs_signing`, `rebuilt`) and, when applicable, the invalidation
|
||||
transaction.
|
||||
|
||||
- `bal/cli/controller.py`
|
||||
- New `auto_rebuild()` method: headless replica of the GUI auto-rebuild
|
||||
flow. Checks coherence, rebuilds with anticipated locktime, handles
|
||||
threshold-crossing (returns invalidation tx), signs passwordless wallets
|
||||
automatically, pushes to will-executors.
|
||||
|
||||
- `tests/test_cli_autorebuild.py` (new)
|
||||
- 10 tests: auto-rebuild returns `valid` when will is coherent, `rebuilt`
|
||||
when rebuilt, `invalidated` with invalidation tx when threshold crossed,
|
||||
`needs_signing` for encrypted wallets, `no_heirs` when heirs are missing.
|
||||
|
||||
- `tests/test_cli_commands_registered.py`
|
||||
- Updated `EXPECTED_COMMANDS` to include `bal_will_autorebuild`.
|
||||
|
||||
- `tests/test_cli_controller_offline.py`
|
||||
- Extended with auto-rebuild flow tests.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed files: no new errors.
|
||||
- Full test suite: 438 passed.
|
||||
|
||||
**Outcome:** DONE.
|
||||
|
||||
---
|
||||
|
||||
## 55. Remove "Add transaction without willexecutor" from settings dialog
|
||||
|
||||
**Date:** 2026-08-17
|
||||
|
||||
**Goal (owner request):** the "Add transaction without willexecutor" checkbox
|
||||
was already available in the Will-Executor tab; showing it redundantly in the
|
||||
settings dialog created confusion. Remove it from the settings dialog and
|
||||
renumber the grid rows.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/gui/qt/plugin.py`
|
||||
- Removed the "Add transaction without willexecutor" checkbox
|
||||
(`NO_WILLEXECUTOR`) from the settings dialog grid. The setting is still
|
||||
functional (available from the Will-Executor tab and the wizard); only
|
||||
the settings-dialog exposure was removed.
|
||||
- Grid rows 5--16 renumbered to 4--15 to close the gap left by the removal.
|
||||
- Removed the corresponding reset-button widget for `NO_WILLEXECUTOR` from
|
||||
the "Reset to Default Setting" list.
|
||||
|
||||
**Verification:**
|
||||
- `ruff check` on changed file: no new errors.
|
||||
- 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
|
||||
`<sink>.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.
|
||||
---
|
||||
|
||||
## 57. Name the real cause of a failed build instead of guessing
|
||||
|
||||
**Date:** 2026-09-04
|
||||
|
||||
**Goal (owner request):** the "Building Will" report was hard to read and often
|
||||
misleading.
|
||||
|
||||
1. The long "could not build the will" block was printed entirely in amber
|
||||
(`COLOR_WARNING`), which the owner reported as barely legible.
|
||||
2. Whenever the build produced nothing, the dialog printed a FIXED list of
|
||||
three "possible reasons" (low balance / dust shares / check-alive later than
|
||||
the delivery date) regardless of what had actually happened. In a case
|
||||
reproduced from the owner's log all three were false, and the real cause
|
||||
(no delivery date left to build) was not even in the list.
|
||||
3. The "Checking your will" row had the same problem: the single sentence
|
||||
"Found CHANGES to the DATE or the HEIRS" was shown for five different
|
||||
situations, including one where it is plainly wrong - funds received, where
|
||||
neither the date nor the heirs changed.
|
||||
|
||||
**What changed:**
|
||||
|
||||
- `bal/core/heirs.py`
|
||||
- `Heirs.__init__` / `buildTransactions`: new `last_build_error` attribute
|
||||
recording WHY a build produced no transaction. Reset at the start of every
|
||||
build, and set at each path that previously returned empty with no
|
||||
explanation at all: `NO_HEIRS`, `NO_UTXO`, `NO_WILLEXECUTOR_USABLE`,
|
||||
`NO_FUTURE_DATE`, `WILLEXECUTOR_FEE`, `WILLEXECUTOR_FEE_TOO_HIGH`,
|
||||
`TX_BUILD_FAILED`, `WILLEXECUTOR_TX_ERROR`.
|
||||
- Added a `processed_willexecutors` counter so that "the loop skipped every
|
||||
will-executor" - which returned silently, with no log line whatsoever - is
|
||||
told apart from "we tried and the build failed".
|
||||
- Fixed a latent crash in the `prepare_transactions` exception handler. It
|
||||
read `e.heirname` in order to auto-deselect the offending will-executor,
|
||||
but NOTHING in the plugin sets that attribute any more (leftover from an
|
||||
older exception design), so the lookup itself raised AttributeError and the
|
||||
inner `except Exception: raise` re-raised THAT, aborting the whole build
|
||||
with a confusing secondary error instead of the real one. The handler now
|
||||
records `WILLEXECUTOR_TX_ERROR`, logs the actual exception together with
|
||||
the will-executor it happened on, and moves on to the next one - which is
|
||||
what the original code was clearly trying to do.
|
||||
|
||||
- `bal/gui/qt/dialogs.py`
|
||||
- New `msg_alert()`: an amber warning sign (U+26A0, written as a numeric HTML
|
||||
entity so the source stays ASCII) followed by text in the theme's default
|
||||
colour. Colour is what ATTRACTS attention, not what is read, so it is kept
|
||||
on the sign alone; the message body stays readable and still works under
|
||||
the dark theme, where a hard-coded black would disappear.
|
||||
- New `_build_failure_message()`: maps `last_build_error` to ONE specific
|
||||
sentence. When the code is missing or unrecognised it SAYS the cause could
|
||||
not be determined and lists what to check, instead of asserting three
|
||||
guesses as if they were the only possibilities.
|
||||
- New `_check_failure_message()`: replaces the single "Found CHANGES to the
|
||||
DATE or the HEIRS" line with seven precise messages, reusing the detail the
|
||||
exceptions already carry (heir name, will-executor URL, old and new fee
|
||||
rate). The two plain `NotCompleteWillException` cases are told apart
|
||||
STRUCTURALLY (raised with no argument vs. with one), not by matching
|
||||
message text, which would be fragile. No new exception classes were added
|
||||
(owner request).
|
||||
- Added a dedicated `except BalanceTooLowException` handler. The exception
|
||||
already carried the balance, the fees and the dust threshold, but was
|
||||
falling through to the generic handler, which printed the raw technical
|
||||
string in red and re-raised. It now shows the real figures.
|
||||
- "Checking variables" row: `No Heirs` now uses `msg_alert()`. The
|
||||
"Check Alive Threshold Passed" message deliberately STAYS red
|
||||
(`COLOR_ERROR`) because it is the more urgent situation (owner request).
|
||||
|
||||
- `bal/gui/qt/common.py`
|
||||
- Re-export `BalanceTooLowException` from `core.heirs` so the Qt layer can
|
||||
catch it.
|
||||
|
||||
**Verification:**
|
||||
- `py_compile` clean on all 44 files of the package.
|
||||
- The real `msg_alert`, `_build_failure_message` and `_check_failure_message`
|
||||
were extracted from the source via AST and executed against every reason code
|
||||
and every exception type, with the exception hierarchy rebuilt from
|
||||
`will.py`: 9 build cases and 8 check cases all produce the intended text.
|
||||
- NOT RUN: the official test suite. The machine used for this task (Windows)
|
||||
has no importable `electrum` module, so `tests/` could not be executed.
|
||||
- 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/<digest>/<frag>` 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).
|
||||
|
||||
@@ -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/<bc32>` | Blockchain Commons / Coldcard-style UR (BC32, SHA-256 digest, part counts per part) |
|
||||
| BC-UR v2 | `ur:bytes/<seq>-<seqlen>/<bytewords>` | BC-UR 2.x fountain codes (CBOR parts, CRC-32, bytewords-minimal) |
|
||||
| BBQR | `B$<enc><type><N><n>…` | 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
|
||||
|
||||
101
HANDOFF.md
101
HANDOFF.md
@@ -58,6 +58,7 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
__init__.py <- package docstring (no version here anymore)
|
||||
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
|
||||
qt.py <- zipimport shim used when loaded as an external ZIP plugin
|
||||
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
|
||||
core/
|
||||
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
||||
heirs.py <- HEIRS + transaction building (prepare_lists,
|
||||
@@ -67,6 +68,14 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
||||
parallel push/check).
|
||||
util.py <- locktime parsing/most helpers (timestamps only).
|
||||
checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free).
|
||||
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
|
||||
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
|
||||
cli/ <- headless command-line layer (no Qt)
|
||||
__init__.py <- registers bal_* commands on import
|
||||
commands.py <- bal_* daemon commands (@plugin_command, async, thin transport)
|
||||
controller.py <- BalController: headless replica of BalWindow (no Qt)
|
||||
plugin.py <- CLI Plugin entry point (extends BalPlugin, no Qt hooks)
|
||||
gui/qt/
|
||||
common.py <- shared imports; every gui module does
|
||||
`from .common import *`. Add new shared imports HERE.
|
||||
@@ -80,7 +89,6 @@ bal/ <- the plugin package (this is what ships in the ZI
|
||||
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
||||
tests/ <- standalone test scripts (see Section 3).
|
||||
docs/ <- user manual + inheritance-options guide (.md sources).
|
||||
bal_cli.py <- headless CLI (heirs/will build/sign/push/check), no Qt.
|
||||
build_zip.py <- builds the shippable ZIP (36 files).
|
||||
CHANGELOG.md <- numbered task log (English).
|
||||
.agent_memory_tasks.md <- terse internal memory notes per task batch.
|
||||
@@ -295,8 +303,8 @@ See Section 5 for details.
|
||||
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
|
||||
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
||||
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
||||
manifest.json version). All merged into `main`.
|
||||
- Releases: latest is **v0.6.1**; v0.6.0 and v0.5.18 before it; the older
|
||||
manifest.json version); all merged into `main`.
|
||||
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
|
||||
v0.2.x line is kept in history.
|
||||
|
||||
---
|
||||
@@ -346,6 +354,11 @@ See Section 5 for details.
|
||||
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
|
||||
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
|
||||
CHANGELOG).
|
||||
- **v0.7.0** — OP_RETURN heirs; core extraction (checkalive, reminders,
|
||||
input_rules); RLock pickle fix; `REBUILD_ON_CLOSE`; headless CLI layer
|
||||
(`bal/cli/`, `bal/cmdline.py`, 30 `bal_*` commands); `AUTO_REBUILD` on new
|
||||
transactions; `bal_will_autorebuild` CLI command; removed redundant
|
||||
"Add transaction without willexecutor" from settings dialog.
|
||||
|
||||
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
|
||||
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
|
||||
@@ -379,3 +392,85 @@ 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`)
|
||||
|
||||
- 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/<bc32>` 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
|
||||
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 <sink>.monitor` (restore after).
|
||||
|
||||
415
PLAN_CMDLINE_PLUGIN.md
Normal file
415
PLAN_CMDLINE_PLUGIN.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Piano: supporto da riga di comando (CLI) per il plugin BAL
|
||||
|
||||
> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato.
|
||||
>
|
||||
> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito.
|
||||
|
||||
---
|
||||
|
||||
## 1. Obiettivo
|
||||
|
||||
Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon
|
||||
di Electrum, senza GUI Qt, esponendo comandi per:
|
||||
|
||||
1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista.
|
||||
2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export.
|
||||
3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default.
|
||||
4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx).
|
||||
|
||||
Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è
|
||||
già GUI-free) e senza importare mai PyQt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stato attuale (verificato sul codice)
|
||||
|
||||
### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`)
|
||||
|
||||
Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
|
||||
`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin:
|
||||
|
||||
- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in
|
||||
`electrum/commands.py:2317`. Un comando plugin:
|
||||
- è **sempre** un `async def`;
|
||||
- viene registrato come `bal_<nome_funzione>` su `Commands` (quindi anche nel parser CLI);
|
||||
- **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`;
|
||||
- alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337).
|
||||
- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`).
|
||||
- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`.
|
||||
- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.<gui_name>` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`.
|
||||
- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag:
|
||||
- `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`);
|
||||
- `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma.
|
||||
- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1.
|
||||
|
||||
### 2.2 Il plugin (bal v0.6.1)
|
||||
|
||||
- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile:
|
||||
- `heirs.py` — `Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`.
|
||||
- `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
|
||||
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
|
||||
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
|
||||
- `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check).
|
||||
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
|
||||
- `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt):
|
||||
- `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
|
||||
- `init_class_variables` (618) e `build_will` (397),
|
||||
- `build_inheritance_transaction` (678) → il flusso completo "prepara will",
|
||||
- `sign_transactions` (952), `ask_password_and_sign_transactions` (1084),
|
||||
- `push_transactions_to_willexecutors` (1164), `broadcast_transactions` (1127),
|
||||
- `check_transactions_task`/`check_transactions` (1414/1464),
|
||||
- `export_json_file` (1246), `merge_will` (1264), `merge_will_from_file` (1348), `_load_will_file` (1406),
|
||||
- `invalidate_will` (917).
|
||||
- `bal/manifest.json`: `"available_for": ["qt"]`, `"version": "0.6.1"`.
|
||||
- `build_zip.py`: cammina ricorsivamente su `bal/` (esclude `__pycache__`, `.pyc`), quindi **includerà automaticamente** i nuovi file di `bal/cli/` e `bal/cmdline.py`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architettura proposta
|
||||
|
||||
```
|
||||
bal/
|
||||
__init__.py # MODIFICATO: importa ``from .cli import commands`` (registra i comandi)
|
||||
cmdline.py # NUOVO: shim zip-safe (come qt.py) che ri-espone Plugin da bal.cli.plugin
|
||||
cli/
|
||||
__init__.py # NUOVO
|
||||
commands.py # NUOVO: tutti i @plugin_command (async), sottili, delegano al controller
|
||||
controller.py # NUOVO: BalController — facciata headless per-wallet (replica di BalWindow senza Qt)
|
||||
plugin.py # NUOVO: class Plugin(BalPlugin) — entry-point per il daemon (gui_name='cmdline')
|
||||
manifest.json # MODIFICATO: available_for = ["qt", "cmdline"]
|
||||
```
|
||||
|
||||
Principi:
|
||||
|
||||
- **`bal/cli/` non importa mai Qt** (stessa regola di `bal/core/`). Può importare solo `bal.core`, `electrum.*` e stdlib.
|
||||
- **`commands.py` = livello di trasporto**: firma `async def bal_x(self, wallet=None, plugin=None, ...)`, valida/parsa argomenti, chiama il controller, ritorna strutture JSON-serializzabili. Zero logica di business.
|
||||
- **`controller.py` = il cuore**: replica i passi GUI-free di `BalWindow`, ma con errori espressi come eccezioni (i messaggi GUI `show_message`/`show_error` diventano raise/ritorni), e persiste esplicitamente su wallet DB.
|
||||
- **`plugin.py`** è quasi vuoto: eredita `BalPlugin.__init__` e basta (serve solo perché Electrum istanzi `module.Plugin(self, config, name)`).
|
||||
- **Nessuna dipendenza nuova** richiesta: `aiohttp`, `dns` e il resto sono già usati da `bal/core`.
|
||||
|
||||
### 3.1 Perché i comandi richiedono il daemon
|
||||
|
||||
`plugin_command` forza il flag `'n'` in `commands.py:2321-2322`. Conseguenza
|
||||
architetturale da documentare chiaramente:
|
||||
|
||||
```
|
||||
electrum daemon -d # avvia il daemon (rete + plugin cmdline)
|
||||
electrum load_wallet # carica/sblocca il wallet
|
||||
electrum bal_heirs_list # i comandi BAL girano contro il daemon
|
||||
```
|
||||
|
||||
Questa è la stessa limitazione di tutti gli altri plugin con comandi CLI
|
||||
(es. `swapserver`, `nwc`). Non è aggirabile senza hackare `plugin_command`, che
|
||||
escludiamo dal piano.
|
||||
|
||||
---
|
||||
|
||||
## 4. Modifiche ai file esistenti
|
||||
|
||||
### 4.1 `bal/manifest.json`
|
||||
- `"available_for": ["qt", "cmdline"]`.
|
||||
|
||||
Nessun cambio di versione necessario per lo sviluppo; la versione si alzerà in
|
||||
`make-release.sh` come già avviene.
|
||||
|
||||
### 4.2 `bal/__init__.py`
|
||||
- Aggiungere in fondo:
|
||||
```python
|
||||
# Registra i comandi CLI (bal_*) appena Electrum importa il pacchetto,
|
||||
# sia in modalità cmd_only (pre-parse) sia nel daemon.
|
||||
from . import cli # noqa: F401 (importa bal.cli.commands, che registra i @plugin_command)
|
||||
```
|
||||
(oppure `from .cli import commands` esplicito).
|
||||
- Accortezza: `bal/cli/commands.py` deve essere importabile **senza Qt** e senza
|
||||
effetti collaterali pesanti, perché viene importato anche nel pre-parse CLI e
|
||||
all'avvio della GUI.
|
||||
|
||||
### 4.3 `build_zip.py`
|
||||
- Nessuna modifica obbligatoria: il walker include già `cli/` e `cmdline.py`.
|
||||
- **Opzionale (consigliato)**: aggiungere una stampa di avviso quando l'archivio
|
||||
contiene sia `cmdline.py` che `qt.py`, e verificare che `manifest.json` abbia
|
||||
entrambi i valori in `available_for`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Nuovi file
|
||||
|
||||
### 5.1 `bal/cmdline.py` (shim, ~stesso schema di `qt.py`)
|
||||
|
||||
Riproduce il pattern zip-safe di `qt.py` (creazione dei package intermedi in
|
||||
`sys.modules`, import via `importlib.import_module`), ma punta a
|
||||
`bal.cli.plugin`:
|
||||
|
||||
```python
|
||||
Plugin = _plugin_module.Plugin
|
||||
```
|
||||
|
||||
### 5.2 `bal/cli/plugin.py`
|
||||
|
||||
```python
|
||||
class Plugin(BalPlugin):
|
||||
def __init__(self, parent, config, name):
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
```
|
||||
|
||||
Niente hook Qt, niente `bal_windows`. Il daemon lo istanzia quando
|
||||
`get_plugin('bal')` viene chiamato dal wrapper di `plugin_command`.
|
||||
|
||||
### 5.3 `bal/cli/controller.py` — `BalController`
|
||||
|
||||
Facciata per-wallet che incapsula lo stato e i flussi. Attributi (speculari a
|
||||
`BalWindow`):
|
||||
- `plugin` (il `BalPlugin`/`Plugin` iniettato),
|
||||
- `wallet` (iniettato da Electrum),
|
||||
- `will_settings` (da `plugin.WILL_SETTINGS.get()` + `Util.fix_will_settings_tx_fees`),
|
||||
- `heirs` (`Heirs(wallet)` validati),
|
||||
- `willexecutors` (`Willexecutors.get_willexecutors(plugin)`),
|
||||
- `willitems` (da `wallet.db.get_dict("will")` → `WillItem(w, wallet=wallet)`),
|
||||
- `date_to_check` (via `resolve_date_to_check`).
|
||||
|
||||
Metodi principali (replicano le funzioni Qt, senza dialoghi):
|
||||
|
||||
| Metodo | Replica di (`window.py`) | Note |
|
||||
|---|---|---|
|
||||
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
|
||||
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
|
||||
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. |
|
||||
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
|
||||
| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. |
|
||||
| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. |
|
||||
| `push_transactions_to_willexecutors(force)` | 1164 | `get_willexecutor_transactions` + `push_transactions_parallel` + gestione "already present" con `check_transaction`. Aggiorna `PUSHED/PUSH_FAIL`. |
|
||||
| `check_transactions()` | 1414 | `check_transactions_parallel` + `set_check_willexecutor(res)` per item. |
|
||||
| `export_json_file(path)` | 1246 | `write_json_file(path, {wid: wi.to_dict()...})` con `tx` come stringa (formato identico a `_load_will_file`). |
|
||||
| `merge_will_from_file(path)` | 1348 | `_load_will_file` + `merge_will` (stessa semantica di `window.py:1264`). |
|
||||
| `_load_will_file(path)` | 1406 | `read_json_file` + `tx_from_any` + `WillItem`. |
|
||||
| `invalidate_will()` | 917 | `Will.invalidate_will(...)` con `history_label` e `will_locktime`. |
|
||||
| `fetch_will_executors_list()` / `ping()` | 1491/1771 | `download_list(old, welist_server)` + `ping_servers_parallel`, poi `Willexecutors.save(plugin, ...)`. |
|
||||
| `apply_settings(cfg_name, value)` | — | Mappa il nome chiave all'attributo `BalConfig` del plugin e fa `set(...)`. |
|
||||
|
||||
Regole di persistenza (fondamentali):
|
||||
- **heirs** → `heirs.save()` (via `__setitem__`/`pop` già implementati) + `wallet.save_db()`.
|
||||
- **will** → `save_willitems()` + `wallet.save_db()`.
|
||||
- **willexecutors** → `Willexecutors.save(plugin, willexecutors)` (config, non wallet DB).
|
||||
- **settings** → `BalConfig.set(...)` (config).
|
||||
|
||||
### 5.4 `bal/cli/commands.py` — comandi (tutti `async def` + `@plugin_command`)
|
||||
|
||||
Firma standard: `async def bal_x(self, wallet=None, plugin=None, ...)`. Flag:
|
||||
- `'n'` — imposto automaticamente da `plugin_command` (rete/daemon).
|
||||
- `'w'` — wallet richiesto e iniettato da Electrum.
|
||||
- `'p'` — solo per i comandi che firmano (richiede `--password`).
|
||||
|
||||
Tutti i comandi costruiscono `controller = BalController(plugin, wallet)` e
|
||||
ritornano strutture JSON-serializzabili. Elenco completo al §6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tabella comandi
|
||||
|
||||
Convenzioni:
|
||||
- `<WALLET>`: wallet caricato nel daemon (non serve passarlo; Electrum usa quello
|
||||
configurato o `--wallet`).
|
||||
- Output: `list`/`dict` stampati come JSON; exit 0 su successo, 1 su errore.
|
||||
- `*` = richiede password (`--password`) se il wallet è cifrato.
|
||||
|
||||
### 6.1 Willexecutors
|
||||
|
||||
| Comando | Flag | Argomenti | Descrizione / output |
|
||||
|---|---|---|---|
|
||||
| `bal_willexecutors_list` | `nw` | — | Elenco `{url: {address, base_fee, status, info, selected, last_update, sort}}` per la chain corrente. |
|
||||
| `bal_willexecutors_show` | `nw` | `url` | Dettaglio di un singolo will-executor. |
|
||||
| `bal_willexecutors_add` | `nw` | `url` `address` `base_fee` | Aggiunge/aggiorna un will-executor (via `initialize_willexecutor`), `selected=false` di default. Ritorna il record. |
|
||||
| `bal_willexecutors_update` | `nw` | `url` `[address]` `[base_fee]` `[info]` `[promo_code]` | Modifica i campi indicati e salva. |
|
||||
| `bal_willexecutors_select` | `nw` | `url` `value` | `is_selected(we, eval_bool(value))` + salva. |
|
||||
| `bal_willexecutors_delete` | `nw` | `url` | Rimuove dalla lista e salva. |
|
||||
| `bal_willexecutors_ping` | `nw` | `[url]` | `ping_servers_parallel` (tutti o uno); aggiorna `status/base_fee/address`; salva. Output: risultati per url. |
|
||||
| `bal_willexecutors_download` | `nw` | — | `download_list(old, plugin.WELIST_SERVER.get())`; unisce e salva. Output: n. record. |
|
||||
| `bal_willexecutors_import` | `nw` | `path` | Legge un JSON `{url: record}` (stesso formato di export), `initialize_willexecutor` per record, salva. |
|
||||
| `bal_willexecutors_export` | `nw` | `path` | Scrive `{url: record}` su file JSON. |
|
||||
|
||||
### 6.2 Heirs
|
||||
|
||||
| Comando | Flag | Argomenti | Descrizione / output |
|
||||
|---|---|---|---|
|
||||
| `bal_heirs_list` | `nw` | — | `{name: [address, amount, locktime, ...]}` (tutte le colonne `HEIR_*`). |
|
||||
| `bal_heirs_show` | `nw` | `name` | Dettaglio di un singolo heir. |
|
||||
| `bal_heirs_add` | `nw` | `name` `address` `amount` `locktime` | Valida con `Heirs.validate_heir` (OP_RETURN incluso) e salva. `amount` può essere satoshi o `"50%"`. `locktime` può essere timestamp assoluto o relativo `"30d"`/`"1y"`. |
|
||||
| `bal_heirs_update` | `nw` | `name` `[address]` `[amount]` `[locktime]` | Modifica i campi indicati (ri-validazione) e salva. |
|
||||
| `bal_heirs_delete` | `nw` | `name` | `heirs.pop(name)` + `save_db()`. |
|
||||
| `bal_heirs_import` | `nw` | `path` | `Heirs.import_file(path)` (validazione + merge). |
|
||||
| `bal_heirs_export` | `nw` | `path` | `Heirs.export_file(path)`. |
|
||||
|
||||
### 6.3 Impostazioni
|
||||
|
||||
| Comando | Flag | Argomenti | Descrizione / output |
|
||||
|---|---|---|---|
|
||||
| `bal_settings_list` | `n` | — | Elenco di tutte le `BalConfig` del plugin: `{chiave: {value, default, name}}` (nome leggibile). |
|
||||
| `bal_settings_get` | `n` | `key` | Valore corrente di una chiave (`bal_*`). |
|
||||
| `bal_settings_set` | `n` | `key=value` | Scrive il valore (conversione di tipo: bool/int/str/JSON) via `BalConfig.set(...)`. `bal_will_settings` accetta JSON. |
|
||||
| `bal_settings_reset` | `n` | `key` | `BalConfig.set(cfg.default)`. |
|
||||
|
||||
### 6.4 Will
|
||||
|
||||
| Comando | Flag | Argomenti | Descrizione / output |
|
||||
|---|---|---|---|
|
||||
| `bal_will_status` | `nw` | — | Per ogni `wid` (txid): locktime, `heirsvalue`, executor, flag di stato (`VALID/COMPLETE/PUSHED/CHECKED/CHECK_FAIL/...`), `sigs_have/sigs_required`, `tx_fees`, executor URL. |
|
||||
| `bal_will_check` | `nw` | — | `check_will()` (coerenza heirs+executor+fees+locktime, in locale). Ritorna `{"valid": true}` o un errore esplicito (es. `HeirNotFound`, `WillPostponed`, `WillExpired`, `NoHeirs`). |
|
||||
| `bal_will_prepare` | `nw` | — | Flusso completo `build_inheritance_transaction`: check → rebuild se non coerente → persiste. Output: riepilogo tx nuova/aggiornata per wid. |
|
||||
| `bal_will_sign` | `nwp` | `[txid]` | Firma i `VALID` non completi (o solo `txid`). Aggiorna `COMPLETE` e `sigs_*`; persiste. Output per txid. |
|
||||
| `bal_will_broadcast` | `nw` | `[txid]` `force` | `push_transactions_to_willexecutors(force, txids)` parallelo; aggiorna `PUSHED/PUSH_FAIL`. Output: `{url: status}`. |
|
||||
| `bal_will_export` | `nw` | `path` | `export_json_file(path)`. |
|
||||
| `bal_will_import_merge` | `nw` | `path` | `merge_will_from_file(path)` (stessa semantica GUI: merge psbt/stati, mai perdere una tx viva). |
|
||||
| `bal_will_invalidate` | `nw` | — | `Will.invalidate_will(...)`; ritorna la tx di invalidazione (da firmare+trasmettere con i comandi sopra). |
|
||||
| `bal_will_check_executor` | `nw` | `[txid]` | Verifica lato will-executor: `check_transactions_parallel` (searchtx) per i `VALID+PUSHED` non `CHECKED`; applica `set_check_willexecutor`. Output: `{wid: {url, checked, ok}}`. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Flusso dati e persistenza
|
||||
|
||||
```
|
||||
CLI (electrum bal_*) Daemon (Electrum 4.8.0)
|
||||
┌───────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ run_electrum │ RPC │ Daemon.run_cmdline │
|
||||
│ pre-parse cmd_only │ ─────────────► │ plugin_command wrapper │
|
||||
│ -> importa bal │ jsonrpc │ inietta plugin + wallet │
|
||||
│ (registra bal_*) │ │ bal/cli/commands.py │
|
||||
└───────────────────────┘ │ -> BalController(plugin, wallet) │
|
||||
│ -> bal.core.* │
|
||||
│ -> wallet.db / config (persist) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Lettura**: `wallet.db.get_dict("will")` (wills), `Heirs(wallet)` (heirs),
|
||||
`plugin.WILLEXECUTORS.get()`/`plugin.WILL_SETTINGS.get()` (config).
|
||||
- **Scrittura**: `save_willitems()` → `wallet.db` + `wallet.save_db()`;
|
||||
`heirs.save()`; `Willexecutors.save(...)`; `BalConfig.set(...)`.
|
||||
- **Firma**: `wallet.sign_transaction(tx, password, ignore_warnings=True)` —
|
||||
idem GUI, quindi compatibile con multisig e wallet cifrati (password via `--password`).
|
||||
- **Rete**: `Network.get_instance()` già usato da `bal/core/willexecutors.py`
|
||||
(i comandi `'n'` garantiscono rete attiva).
|
||||
|
||||
---
|
||||
|
||||
## 8. Errori, exit code, output
|
||||
|
||||
- Ritorno `None` → nessun output; `str` → stampato; `dict`/`list` → `json_encode`.
|
||||
- Errori utente: sollevare `electrum.util.UserFacingException(msg)` → in modalità
|
||||
daemon viene stampato `msg` con exit 1.
|
||||
- Errori di dominio BAL (`WillExpiredException`, `WillPostponedException`,
|
||||
`HeirNotFoundException`, `NoWillExecutorNotPresent`, `CheckAliveError`,
|
||||
`AmountException`, ...): il controller le converte in `UserFacingException`
|
||||
con testo in chiaro (riuso dei messaggi già presenti, senza HTML/Qt).
|
||||
- Convenzione consigliata per comandi che producono più di un risultato:
|
||||
ritornare un `dict` con chiave `"result"`/`"warnings"` quando servono avvisi
|
||||
(es. dopo `prepare` con heirs scartati per dust).
|
||||
|
||||
---
|
||||
|
||||
## 9. Compatibilità Electrum 4.7.2 / 4.8.0
|
||||
|
||||
- `plugin_command`, il wrapper `@command` e `daemon._plugins.get_plugin` esistono
|
||||
in entrambe le versioni (verificati su 4.8.0; usati identici da `swapserver`).
|
||||
- Il `BalPlugin` già gestisce il cambio API di registrazione dict
|
||||
(`json_db.register_dict` vs `stored_dict.register_name`): nessun intervento.
|
||||
- `available_for: ["cmdline"]` è lo stesso meccanismo di `trustedcoin`
|
||||
(che ha già `cmdline.py` in 4.8.0).
|
||||
- **Nessun nuovo import Qt** in `bal/cli/`: verificabile in CI con un check
|
||||
statico su `bal/cli/*.py` e `bal/cmdline.py`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Build / release
|
||||
|
||||
- `python3 build_zip.py` produce `bal-electrum-plugin.zip` con `cli/`, `cmdline.py`
|
||||
e il manifest aggiornato. Lo zip serve sia per la GUI che per il daemon.
|
||||
- Il test `external_zip_test.py` andrà esteso (vedi §11) per verificare che il
|
||||
zip, caricato da Electrum, registri anche i comandi `bal_*`.
|
||||
- Nessun cambiamento a `make-release.sh` (la versione resta nel manifest).
|
||||
|
||||
---
|
||||
|
||||
## 11. Piano di test e verifica
|
||||
|
||||
### 11.1 Nuovi test standalone (stile repo: `tests/test_*.py` con `if __name__ == "__main__"`)
|
||||
|
||||
- `tests/test_cli_commands_registered.py` (runtime env):
|
||||
- importa `electrum.plugins.bal` con `Plugins(config, cmd_only=True)`;
|
||||
- asserisce che `known_commands` contenga tutti i nomi `bal_*` della tabella;
|
||||
- asserisce che ogni funzione sia coroutine e abbia il flag `n`.
|
||||
- `tests/test_cli_controller.py` (runtime env, offline, senza rete):
|
||||
- wallet "fake"/temporaneo (pattern di `test_core_heirs.py`);
|
||||
- CRUD heirs e willexecutors, settings get/set/reset, export/import will
|
||||
(merge), build will con fixtures note.
|
||||
- `tests/test_cli_zip.py` (o estensione di `external_zip_test.py`):
|
||||
- costruisce lo zip, lo carica come `electrum_external_plugins.bal` con
|
||||
`Plugins(config, 'cmdline')`, asserisce `available_for` include `"cmdline"`
|
||||
e che `get_plugin('bal')` restituisca il `Plugin` di `bal.cli.plugin`
|
||||
(nessun import Qt eseguito).
|
||||
- `tests/test_cli_will_flows.py` (offline, dove possibile):
|
||||
- prepare → sign → export → merge su un wallet di test con heirs fissi;
|
||||
- verifica che `wallet.db.get_dict("will")` rifletta COMPLETE/PUSHED dopo
|
||||
le operazioni che non toccano rete.
|
||||
|
||||
### 11.2 Verifica manuale (da documentare nel README/HANDOFF)
|
||||
|
||||
```bash
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
electrum daemon -d
|
||||
electrum load_wallet
|
||||
electrum bal_heirs_list
|
||||
electrum bal_settings_list
|
||||
electrum bal_will_status
|
||||
electrum bal_will_prepare
|
||||
electrum bal_will_sign --password '...' # se wallet cifrato
|
||||
electrum bal_will_broadcast
|
||||
electrum bal_will_check_executor
|
||||
electrum bal_willexecutors_ping
|
||||
electrum stop
|
||||
```
|
||||
|
||||
### 11.3 Regressione
|
||||
|
||||
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
|
||||
deve continuare a passare (prova che `bal/__init__` + Qt convivono con il
|
||||
nuovo import di `bal.cli.commands`).
|
||||
- Eseguire i `test_core_*.py` esistenti (nessuna logica core toccata).
|
||||
- Ruff: evitare nuove violazioni in `bal/cli/`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Rischi e decisioni aperte
|
||||
|
||||
1. **Daemon obbligatorio** (non `--offline`): imposto da `plugin_command`.
|
||||
→ Accettato; documentato al §3.1.
|
||||
2. **Wallet pre-caricato**: i comandi `w` falliscono con "wallet not loaded" se
|
||||
non si lancia prima `electrum load_wallet`. → Documentare.
|
||||
3. **`bal/__init__.py` che importa `bal.cli.commands`**: viene eseguito anche
|
||||
all'avvio della GUI. `commands.py` deve restare leggero (solo definizioni +
|
||||
import di `electrum.commands` e `bal.core`). Da verificare con `smoke_test.py`.
|
||||
4. **Doppio caricamento**: se un install è contemporaneamente interno E zip
|
||||
esterno, la seconda importazione di `commands.py` potrebbe sollevare
|
||||
"Command name bal_... already exists". Pratica corrente: un solo install;
|
||||
si può mitigare con un guard `if not getattr(module, '_registered')`.
|
||||
5. **OP_RETURN heirs** in CLI: gestiti come in GUI (`validate_op_return_hex`,
|
||||
colonne quantità `"0"`). Da testare.
|
||||
6. **Persistenza `will_settings`**: oggi letta dalla config globale
|
||||
(`bal_will_settings`) in `BalWindow.__init__`, non dal wallet DB. Il
|
||||
controller deve replicare esattamente questo (config), non introdurre una
|
||||
seconda sorgente.
|
||||
7. **Multisig**: la firma usa `wallet.sign_transaction` → supportata; il flusso
|
||||
"merge PSBT" copre la firma parziale. Test dedicato con wallet multisig in
|
||||
fase di implementazione.
|
||||
|
||||
---
|
||||
|
||||
## 13. Fasi di implementazione (ordine proposto)
|
||||
|
||||
1. `bal/cli/__init__.py`, `bal/cli/plugin.py`, `bal/cmdline.py`, update
|
||||
`bal/manifest.json` + `bal/__init__.py`.
|
||||
2. `tests/test_cli_commands_registered.py` + verifica `smoke_test.py`.
|
||||
3. `bal/cli/controller.py` (read-only: status/list/show) → `commands.py` per
|
||||
willexecutors/heirs/settings (senza rete).
|
||||
4. Comandi will: `prepare`, `sign`, `export`, `import_merge`, `invalidate`.
|
||||
5. Comandi di rete: `ping`, `download`, `broadcast`, `check_executor`.
|
||||
6. Test zip (`test_cli_zip.py`), estensione `external_zip_test.py`, prova
|
||||
manuale col daemon, aggiornamento README/HANDOFF.
|
||||
482
PLAN_QR_TRANSFER.md
Normal file
482
PLAN_QR_TRANSFER.md
Normal file
@@ -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|<total>|<index>|<flags>|<payload>
|
||||
```
|
||||
|
||||
- Magic+version literal `BALQR1` (reject anything else with a clear message;
|
||||
keeps the door open for a future `BALQR2`).
|
||||
- `<total>` N, `<index>` i — integers, `1 ≤ i ≤ N`.
|
||||
- `<flags>`: subset of chars, today `` (empty ⇒ plain) or `Z` (compressed).
|
||||
- `<payload>`: 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.
|
||||
379
QML_PLAN.md
Normal file
379
QML_PLAN.md
Normal file
@@ -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 `<plugin>/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 `<plugins>/<name>/qml/<loader>.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. | `$BAL_HOME/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).
|
||||
81
README.md
81
README.md
@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
|
||||
that transfer your funds to your heirs if you stop refreshing them
|
||||
(dead-man's switch), optionally relayed by will-executor servers.
|
||||
|
||||
This repository contains a **behavior-preserving refactor** of the original
|
||||
plugin. The logic was kept byte-identical wherever possible; only the file
|
||||
layout was reorganized to cleanly separate **business logic** from the
|
||||
**PyQt GUI**.
|
||||
This repository contains a **refactored and extended** version of the original
|
||||
plugin. The logic was reorganized to cleanly separate **business logic** from the
|
||||
**PyQt GUI**, and new features have been added including a headless CLI,
|
||||
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
|
||||
reminders.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -16,12 +17,22 @@ layout was reorganized to cleanly separate **business logic** from the
|
||||
bal/ the installable Electrum plugin package
|
||||
├── manifest.json plugin metadata (Electrum reads this)
|
||||
├── qt.py Qt entry-point shim (re-exports Plugin)
|
||||
├── cmdline.py CLI entry-point shim (re-exports Plugin)
|
||||
├── core/ GUI-free logic (importable without Qt)
|
||||
│ ├── util.py
|
||||
│ ├── plugin_base.py
|
||||
│ ├── heirs.py
|
||||
│ ├── will.py
|
||||
│ └── willexecutors.py
|
||||
│ ├── willexecutors.py
|
||||
│ ├── checkalive.py
|
||||
│ ├── reminders.py
|
||||
│ ├── 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)
|
||||
│ ├── controller.py headless BalController (replicates BalWindow)
|
||||
│ └── plugin.py CLI Plugin entry point
|
||||
├── gui/qt/ PyQt6 presentation layer
|
||||
│ ├── theme.py status → color mapping
|
||||
│ ├── common.py shared imports / helpers
|
||||
@@ -30,6 +41,7 @@ bal/ the installable Electrum plugin package
|
||||
│ ├── dialogs.py dialog windows
|
||||
│ ├── lists.py tree/list views
|
||||
│ ├── window.py per-wallet GUI controller
|
||||
│ ├── window_utils.py GUI utility helpers
|
||||
│ └── plugin.py Plugin (Electrum @hooks → GUI)
|
||||
├── icons/ wallet_util/ LICENSE README.md
|
||||
build_zip.py builds a clean, zipimport-friendly distribution zip
|
||||
@@ -77,6 +89,65 @@ 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 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
|
||||
|
||||
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
||||
exposes `bal_*` commands that replicate the full inheritance cycle.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- An **Electrum daemon** running (`electrum daemon -d`)
|
||||
- A wallet loaded (`electrum load_wallet`)
|
||||
|
||||
### Available commands
|
||||
|
||||
| Category | Commands |
|
||||
|----------|----------|
|
||||
| Settings | `bal_settings_list`, `bal_settings_get`, `bal_settings_set`, `bal_settings_reset` |
|
||||
| Heirs | `bal_heirs_list`, `bal_heirs_show`, `bal_heirs_add`, `bal_heirs_update`, `bal_heirs_delete`, `bal_heirs_import`, `bal_heirs_export` |
|
||||
| Will-Executors | `bal_willexecutors_list`, `bal_willexecutors_show`, `bal_willexecutors_add`, `bal_willexecutors_update`, `bal_willexecutors_select`, `bal_willexecutors_delete`, `bal_willexecutors_ping`, `bal_willexecutors_download`, `bal_willexecutors_import`, `bal_willexecutors_export` |
|
||||
| Will | `bal_will_status`, `bal_will_check`, `bal_will_prepare`, `bal_will_autorebuild`, `bal_will_sign`, `bal_will_broadcast`, `bal_will_export`, `bal_will_import_merge`, `bal_will_invalidate`, `bal_will_check_executor` |
|
||||
|
||||
### Example workflow
|
||||
|
||||
```bash
|
||||
electrum daemon -d
|
||||
electrum load_wallet
|
||||
electrum bal_heirs_list
|
||||
electrum bal_will_prepare
|
||||
electrum bal_will_sign --password '...'
|
||||
electrum bal_will_broadcast
|
||||
electrum stop
|
||||
```
|
||||
|
||||
All commands require a running daemon (Electrum's `plugin_command` enforces
|
||||
this). Wallet-bound commands (`bal_heirs_*`, `bal_will_*`, etc.) require the
|
||||
wallet to be loaded first. Signing commands require `--password` for encrypted
|
||||
wallets.
|
||||
|
||||
## Inheritance safety: anticipate / postpone
|
||||
|
||||
A will transaction is signed with a **fixed, immutable locktime** and then
|
||||
|
||||
@@ -24,11 +24,18 @@ distinct sub-packages:
|
||||
lists.py Tree/list views (heirs, preview, will-executors)
|
||||
window.py BalWindow controller (per-wallet GUI state)
|
||||
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
||||
cli/ Headless command-line layer (no Qt)
|
||||
commands.py The @plugin_command transport layer (registers
|
||||
the ``bal_*`` commands)
|
||||
controller.py Headless replica of the Qt flows (later phases)
|
||||
plugin.py Plugin(BalPlugin) entry point for the daemon
|
||||
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
||||
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
||||
from ``gui.qt.plugin``.
|
||||
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
|
||||
(the shim), which imports ``Plugin`` from ``cli.plugin``.
|
||||
|
||||
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
||||
``json_db.register_dict`` and replaced it with the path-based
|
||||
@@ -40,3 +47,85 @@ available and adapts, so both releases keep working.
|
||||
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
||||
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
||||
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI command registration
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
|
||||
# only imports the plugin package ``__init__`` to discover its commands.
|
||||
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
|
||||
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
|
||||
# commands become available on the command line and over JSON-RPC without any Qt.
|
||||
#
|
||||
# The import must be zip-safe: when the plugin is loaded as an external zip,
|
||||
# Electrum registers the package under the synthetic name
|
||||
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
|
||||
# ``bal`` (the zip-internal directory name), which is not present in
|
||||
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
|
||||
# resolve the real package name and import through ``importlib`` (the same
|
||||
# trick as ``qt.py``).
|
||||
import importlib
|
||||
import sys as _sys
|
||||
|
||||
|
||||
def _resolve_package_name() -> str:
|
||||
"""Return the name this package is registered under in ``sys.modules``.
|
||||
|
||||
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
|
||||
so ``__package__`` is already correct). External zip plugins are imported
|
||||
under the synthetic name ``electrum_external_plugins.bal`` with
|
||||
``__package__`` set to just the zip-internal directory name (``bal``); only
|
||||
the synthetic name is present in ``sys.modules``.
|
||||
"""
|
||||
pkg = __package__ or "bal"
|
||||
if pkg in _sys.modules:
|
||||
return pkg
|
||||
synthetic = "electrum_external_plugins." + __name__
|
||||
if synthetic in _sys.modules:
|
||||
return synthetic
|
||||
return pkg
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Backfill missing ancestor packages in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
|
||||
may be missing, which would break sub-module imports. We stub it out as a
|
||||
namespace package so ``importlib`` can still resolve its children (same
|
||||
helper as ``qt.py``).
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in _sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
_sys.modules[ancestor] = module
|
||||
|
||||
|
||||
def _register_cli_commands() -> None:
|
||||
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
|
||||
|
||||
Guarded so a dual install (internal package AND external zip) cannot
|
||||
register the same command names twice, which would make
|
||||
``electrum.commands.plugin_command`` raise
|
||||
"Command name bal_... already exists".
|
||||
"""
|
||||
from electrum import commands as _electrum_commands
|
||||
|
||||
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
|
||||
return
|
||||
pkg = _resolve_package_name()
|
||||
_ensure_parent_packages(pkg)
|
||||
importlib.import_module(pkg + ".cli.commands")
|
||||
_electrum_commands._bal_cli_commands_registered = True
|
||||
|
||||
|
||||
_register_cli_commands()
|
||||
|
||||
19
bal/cli/__init__.py
Normal file
19
bal/cli/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
bal.cli
|
||||
=======
|
||||
|
||||
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||
|
||||
This sub-package implements the ``"cmdline"`` front-end: it exposes the
|
||||
plugin's functionality through Electrum ``bal_*`` commands while reusing only
|
||||
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
|
||||
PyQt or ``electrum.gui``.
|
||||
|
||||
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
|
||||
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
|
||||
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
|
||||
|
||||
Electrum discovers the plugin through ``manifest.json`` (``available_for``
|
||||
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
|
||||
zip-safe shim following the same pattern as ``qt.py``.
|
||||
"""
|
||||
424
bal/cli/commands.py
Normal file
424
bal/cli/commands.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
bal.cli.commands
|
||||
================
|
||||
|
||||
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
|
||||
|
||||
This module is the *transport layer* of the command-line front-end: every
|
||||
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
|
||||
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
|
||||
validate their arguments and delegate the real work to
|
||||
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
|
||||
never imports Qt.
|
||||
|
||||
It must stay lightweight: Electrum imports it during the CLI pre-parse
|
||||
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
|
||||
GUI/daemon startup, before any wallet or network object exists. The heavy
|
||||
imports (``bal.core``, the controller) happen lazily inside each command.
|
||||
|
||||
Flags (see ``electrum.commands.plugin_command``):
|
||||
|
||||
* ``n`` -> requires a running daemon/network (always set for plugins);
|
||||
* ``w`` -> resolves and injects the wallet from the daemon;
|
||||
* ``p`` -> requires the wallet password (for signing).
|
||||
"""
|
||||
|
||||
from electrum.commands import plugin_command
|
||||
from electrum.util import UserFacingException
|
||||
|
||||
from .controller import BalController, _user_facing
|
||||
|
||||
plugin_name = "bal"
|
||||
|
||||
|
||||
def _controller(plugin, wallet):
|
||||
"""Build the headless controller, or fail with a clear message."""
|
||||
if plugin is None:
|
||||
raise UserFacingException("the bal plugin is not enabled in this daemon")
|
||||
if wallet is None:
|
||||
raise UserFacingException("wallet not loaded")
|
||||
return BalController(plugin, wallet)
|
||||
|
||||
|
||||
def _call(plugin, wallet, method, *args, **kwargs):
|
||||
controller = _controller(plugin, wallet)
|
||||
try:
|
||||
return getattr(controller, method)(*args, **kwargs)
|
||||
except Exception as e:
|
||||
raise _user_facing(e) from e
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Settings
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_list(self, plugin=None):
|
||||
"""List all BAL plugin configuration options (key, name and value).
|
||||
|
||||
Returns a JSON object mapping every BAL configuration option (``bal_*``)
|
||||
to an object with ``value``, ``default`` and ``name``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_list")
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_get(self, key, plugin=None):
|
||||
"""Show the current value of one BAL configuration option.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_get", key)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_set(self, key, value, plugin=None):
|
||||
"""Set a BAL configuration option (booleans, integers, strings, JSON).
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_user_type``).
|
||||
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
|
||||
"""
|
||||
return _call(plugin, None, "settings_set", key, value)
|
||||
|
||||
|
||||
@plugin_command("n", plugin_name)
|
||||
async def settings_reset(self, key, plugin=None):
|
||||
"""Reset a BAL configuration option to its default value.
|
||||
|
||||
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||
"""
|
||||
return _call(plugin, None, "settings_reset", key)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Heirs
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_list(self, wallet=None, plugin=None):
|
||||
"""List the heirs of the current wallet.
|
||||
|
||||
Returns a JSON object mapping heir names to their ``[address, amount,
|
||||
locktime]`` values.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_show(self, name, wallet=None, plugin=None):
|
||||
"""Show the details of a single heir.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_show", name)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
|
||||
"""Add (or replace) an heir in the current wallet.
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
|
||||
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
|
||||
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_update(
|
||||
self,
|
||||
name,
|
||||
address=None,
|
||||
amount=None,
|
||||
locktime=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing heir (only the given fields).
|
||||
|
||||
arg:str:name:The heir name.
|
||||
arg:str:address:The new destination address.
|
||||
arg:str:amount:The new amount in satoshis or a percentage.
|
||||
arg:str:locktime:The new delivery locktime.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_delete(self, names, wallet=None, plugin=None):
|
||||
"""Delete one or more heirs.
|
||||
|
||||
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_delete", names)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_import(self, path, wallet=None, plugin=None):
|
||||
"""Import heirs from a JSON file (validated, merged).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def heirs_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the heirs to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "heirs_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will-Executors
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_list(self, wallet=None, plugin=None):
|
||||
"""List the will-executors for the current network.
|
||||
|
||||
Returns a JSON object mapping executor URLs to their records (address,
|
||||
base_fee, status, info, selected, ...).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_list")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_show(self, url, wallet=None, plugin=None):
|
||||
"""Show the details of a single will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_show", url)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_add(
|
||||
self,
|
||||
url,
|
||||
address="",
|
||||
base_fee=0,
|
||||
info=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Add a new will-executor (not selected by default).
|
||||
|
||||
arg:str:url:The will-executor base URL.
|
||||
arg:str:address:The executor fee address for this network.
|
||||
arg:int:base_fee:The executor base fee in satoshis.
|
||||
arg:str:info:A human-readable description.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_update(
|
||||
self,
|
||||
url,
|
||||
address=None,
|
||||
base_fee=None,
|
||||
info=None,
|
||||
promo_code=None,
|
||||
rename_to=None,
|
||||
wallet=None,
|
||||
plugin=None,
|
||||
):
|
||||
"""Update an existing will-executor (only the given fields).
|
||||
|
||||
arg:str:url:The will-executor URL to update.
|
||||
arg:str:address:The new fee address.
|
||||
arg:int:base_fee:The new base fee in satoshis.
|
||||
arg:str:info:The new description.
|
||||
arg:str:promo_code:The new promo code.
|
||||
arg:str:rename_to:Optionally move the record to a new URL.
|
||||
"""
|
||||
return _call(
|
||||
plugin,
|
||||
wallet,
|
||||
"willexecutors_update",
|
||||
url,
|
||||
address,
|
||||
base_fee,
|
||||
info,
|
||||
promo_code,
|
||||
rename_to,
|
||||
)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_select(
|
||||
self, url, value=True, wallet=None, plugin=None
|
||||
):
|
||||
"""Select (or deselect) a will-executor.
|
||||
|
||||
arg:str:url:The will-executor URL.
|
||||
arg:bool:value:True to select, False to deselect.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
|
||||
"""Delete one or more will-executors.
|
||||
|
||||
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_delete", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
|
||||
"""Ping the selected (or the given) will-executor servers.
|
||||
|
||||
Updates status/base_fee/address from each server and saves. Returns
|
||||
``{url: {status, ok}}``.
|
||||
|
||||
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_ping", urls)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_download(self, wallet=None, plugin=None):
|
||||
"""Download the will-executor list from the welist server and merge it.
|
||||
|
||||
Returns the number of records downloaded and the new total.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_download")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||
"""Import will-executors from a JSON file (``{url: record}``).
|
||||
|
||||
arg:str:path:Path to the JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_import", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the will-executors to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "willexecutors_export", path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Will
|
||||
# --------------------------------------------------------------------------- #
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_status(self, wallet=None, plugin=None):
|
||||
"""Show the current will: per-transaction status, locktime and executors.
|
||||
|
||||
Returns a JSON object with a per-txid detail list and global status counts.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_status")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check(self, wallet=None, plugin=None):
|
||||
"""Check the local coherence of the will (heirs, executors, fees, locktime).
|
||||
|
||||
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
|
||||
"""
|
||||
return _call(plugin, wallet, "will_check")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_prepare(self, wallet=None, plugin=None):
|
||||
"""Run the full prepare/inheritance flow (check, rebuild, persist).
|
||||
|
||||
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
|
||||
``expired``, ``postponed``) and, when needed, the invalidation
|
||||
transaction to sign and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "prepare_will")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_autorebuild(self, wallet=None, plugin=None):
|
||||
"""Run the automatic rebuild flow in one shot (check, rebuild, sign, push).
|
||||
|
||||
The same flow the GUI runs automatically on new wallet transactions:
|
||||
the delivery date is anticipated by one day to orphan the old will on-chain
|
||||
and, only when the anticipated locktime crosses the Check Alive threshold
|
||||
(or the threshold is already in the past), an invalidation transaction is
|
||||
returned instead. Signing needs a passwordless wallet.
|
||||
|
||||
Returns a JSON object with ``result``: ``valid``, ``no_heirs``,
|
||||
``invalidated`` (with ``invalidation_tx``), ``nothing``,
|
||||
``needs_signing`` or ``rebuilt``.
|
||||
"""
|
||||
return _call(plugin, wallet, "auto_rebuild")
|
||||
|
||||
|
||||
@plugin_command("nwp", plugin_name)
|
||||
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
|
||||
"""Sign the valid, not-yet-complete will transactions (or just one).
|
||||
|
||||
Updates the COMPLETE status and the signature counters and persists.
|
||||
|
||||
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
txs = _call(plugin, wallet, "sign_transactions", password, txids)
|
||||
return {wid: str(tx) for wid, tx in txs.items()}
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_broadcast(
|
||||
self, txid=None, force=False, wallet=None, plugin=None
|
||||
):
|
||||
"""Send the signed will transactions to their will-executors (in parallel).
|
||||
|
||||
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
|
||||
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_export(self, path, wallet=None, plugin=None):
|
||||
"""Export the whole will to a JSON file.
|
||||
|
||||
arg:str:path:Destination file path.
|
||||
"""
|
||||
return _call(plugin, wallet, "export_will", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_import_merge(self, path, wallet=None, plugin=None):
|
||||
"""Merge a will file into the current will (PSBTs and statuses are merged).
|
||||
|
||||
arg:str:path:Path to the will JSON file.
|
||||
"""
|
||||
return _call(plugin, wallet, "merge_will_from_file", path)
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_invalidate(self, wallet=None, plugin=None):
|
||||
"""Build the on-chain invalidation transaction for the current will.
|
||||
|
||||
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
|
||||
transaction still needs to be signed and broadcast.
|
||||
"""
|
||||
return _call(plugin, wallet, "invalidate_will_command")
|
||||
|
||||
|
||||
@plugin_command("nw", plugin_name)
|
||||
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
|
||||
"""Ask the will-executors whether they hold our pushed transactions.
|
||||
|
||||
Runs the searchtx check in parallel, applies the per-item status and
|
||||
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
|
||||
|
||||
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
|
||||
"""
|
||||
txids = [txid] if txid is not None else None
|
||||
return _call(plugin, wallet, "check_transactions", txids)
|
||||
1245
bal/cli/controller.py
Normal file
1245
bal/cli/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
21
bal/cli/plugin.py
Normal file
21
bal/cli/plugin.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
bal.cli.plugin
|
||||
==============
|
||||
|
||||
The headless (command-line) entry point of the plugin.
|
||||
|
||||
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
|
||||
adding any Qt hooks or per-window state. Electrum instantiates this class when
|
||||
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
|
||||
``bal/cmdline.py``, which re-exports it), and it is the object injected as
|
||||
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
|
||||
"""
|
||||
|
||||
from ..core.plugin_base import BalPlugin
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
|
||||
|
||||
def __init__(self, parent, config, name):
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
69
bal/cmdline.py
Normal file
69
bal/cmdline.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
bal.cmdline
|
||||
===========
|
||||
|
||||
Compatibility shim for Electrum's plugin loader (command-line front-end).
|
||||
|
||||
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
|
||||
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
|
||||
The real implementation lives in the ``bal.cli`` sub-package, so this module
|
||||
re-exports ``Plugin`` from ``bal.cli.plugin``.
|
||||
|
||||
Like ``qt.py``, this file is not a one-line relative import because the very
|
||||
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
|
||||
imports the package under the synthetic top-level name
|
||||
``electrum_external_plugins.bal`` and never registers the intermediate parent
|
||||
packages. See the module docstring of ``bal.qt`` for the full rationale. The
|
||||
shim resolves the run-time package name, backfills the missing parents into
|
||||
``sys.modules`` and imports the real implementation via
|
||||
:func:`importlib.import_module`.
|
||||
|
||||
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
|
||||
headless process).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
|
||||
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
|
||||
|
||||
When loaded from a zip as an external plugin, Electrum only executes the
|
||||
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
|
||||
package (e.g. ``electrum_external_plugins``) and any intermediate packages
|
||||
may be missing from ``sys.modules``, which breaks relative/absolute
|
||||
sub-module imports. We backfill them here using this module's own loader
|
||||
so that ``importlib`` can find sibling sub-packages.
|
||||
"""
|
||||
parts = pkg_name.split(".")
|
||||
# Walk from the top-most ancestor down to (but not including) pkg_name.
|
||||
for i in range(1, len(parts)):
|
||||
ancestor = ".".join(parts[:i])
|
||||
if ancestor in sys.modules:
|
||||
continue
|
||||
try:
|
||||
importlib.import_module(ancestor)
|
||||
except Exception:
|
||||
# The synthetic root (e.g. 'electrum_external_plugins') often has no
|
||||
# real spec. Create a minimal namespace package stub so that the
|
||||
# import machinery can still resolve its children.
|
||||
import types
|
||||
|
||||
module = types.ModuleType(ancestor)
|
||||
module.__path__ = [] # mark as a (namespace) package
|
||||
sys.modules[ancestor] = module
|
||||
|
||||
|
||||
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
|
||||
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
|
||||
# loaded us.
|
||||
_PKG = __package__ or "bal"
|
||||
|
||||
_ensure_parent_packages(_PKG)
|
||||
|
||||
# Import the real implementation using the fully-qualified, run-time package
|
||||
# name so it works regardless of the synthetic prefix Electrum assigned.
|
||||
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
|
||||
|
||||
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)
|
||||
1178
bal/core/animated_qr.py
Normal file
1178
bal/core/animated_qr.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .plugin_base import BalTimestamp
|
||||
@@ -24,7 +24,7 @@ class CheckAliveError(Exception):
|
||||
|
||||
def __str__(self):
|
||||
return "Check alive expired please update it: {}".format(
|
||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def resolve_date_to_check(
|
||||
The reference timestamp (float, UNIX seconds).
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return (now if now is not None else datetime.now().timestamp())
|
||||
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
|
||||
|
||||
threshold = BalTimestamp(will_settings["threshold"])
|
||||
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
||||
@@ -107,5 +107,5 @@ def check_alive_expired(
|
||||
"""
|
||||
if is_basic_mode:
|
||||
return False
|
||||
current = now if now is not None else datetime.now().timestamp()
|
||||
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||
return date_to_check < current
|
||||
|
||||
@@ -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):
|
||||
@@ -363,6 +337,10 @@ class Heirs(dict, Logger):
|
||||
Logger.__init__(self)
|
||||
self.db = wallet.db
|
||||
self.wallet = wallet
|
||||
# Reason code explaining why the last buildTransactions() produced no
|
||||
# transaction (None when the last build succeeded or never ran). See
|
||||
# buildTransactions for the list of codes and why they exist.
|
||||
self.last_build_error = None
|
||||
d = self.db.get("heirs", {})
|
||||
try:
|
||||
self.update(d)
|
||||
@@ -630,6 +608,20 @@ class Heirs(dict, Logger):
|
||||
def buildTransactions(
|
||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||
):
|
||||
# Reset the diagnostic reason at the start of every build attempt.
|
||||
#
|
||||
# WHY: when the build produced nothing, the GUI used to show a fixed
|
||||
# list of three "possible reasons" (low balance / dust shares /
|
||||
# check-alive after the delivery date). In practice the real cause is
|
||||
# often NONE of those three - several code paths below simply return
|
||||
# an empty result with no explanation at all, so the user was shown
|
||||
# three guesses that were all wrong. Each such path now records WHY
|
||||
# it gave up, and BalBuildWillDialog names the actual cause.
|
||||
#
|
||||
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
|
||||
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
|
||||
# WILLEXECUTOR_TX_ERROR.
|
||||
self.last_build_error = None
|
||||
_before = list(self.keys())
|
||||
Heirs._validate(self, persist=False)
|
||||
_removed = [k for k in _before if k not in self]
|
||||
@@ -644,6 +636,7 @@ class Heirs(dict, Logger):
|
||||
", ".join(_removed),
|
||||
)
|
||||
if len(self) <= 0:
|
||||
self.last_build_error = "NO_HEIRS"
|
||||
_logger.info("while building transactions there was no heirs")
|
||||
return
|
||||
balance = 0.0
|
||||
@@ -660,12 +653,18 @@ class Heirs(dict, Logger):
|
||||
len_utxo_set += 1
|
||||
available_utxos.append(utxo)
|
||||
if len_utxo_set == 0:
|
||||
self.last_build_error = "NO_UTXO"
|
||||
_logger.info("no usable utxos")
|
||||
return
|
||||
j = -2
|
||||
willexecutorsitems = list(willexecutors.items())
|
||||
willexecutorslen = len(willexecutorsitems)
|
||||
alltxs = {}
|
||||
# Counts how many will-executors were actually PROCESSED (i.e. passed
|
||||
# the is_selected/is_valid filter below and reached the build loop).
|
||||
# If it stays 0 the loop silently skipped every single one, which is a
|
||||
# distinct failure from "we tried and the build failed".
|
||||
processed_willexecutors = 0
|
||||
while True:
|
||||
j += 1
|
||||
if j >= willexecutorslen:
|
||||
@@ -682,6 +681,7 @@ class Heirs(dict, Logger):
|
||||
url = willexecutor = None
|
||||
else:
|
||||
break
|
||||
processed_willexecutors += 1
|
||||
fees = {}
|
||||
i = 0
|
||||
txs = {}
|
||||
@@ -699,9 +699,11 @@ class Heirs(dict, Logger):
|
||||
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||
)
|
||||
except WillExecutorFeeException:
|
||||
self.last_build_error = "WILLEXECUTOR_FEE"
|
||||
i = 10
|
||||
continue
|
||||
except WillExecutorFeeTooHighException:
|
||||
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
|
||||
i = 10
|
||||
continue
|
||||
if locktimes:
|
||||
@@ -710,19 +712,33 @@ class Heirs(dict, Logger):
|
||||
locktimes, available_utxos[:], fees, wallet
|
||||
)
|
||||
if not txs:
|
||||
self.last_build_error = "TX_BUILD_FAILED"
|
||||
return {}
|
||||
except Exception as e:
|
||||
# An unexpected failure while assembling the
|
||||
# transactions for THIS will-executor.
|
||||
#
|
||||
# WHY THIS CHANGED: the previous code read
|
||||
# ``e.heirname`` here, in order to auto-deselect the
|
||||
# will-executor blamed by the exception. NOTHING in
|
||||
# the plugin sets that attribute any more (it is a
|
||||
# leftover from an older exception design), so the
|
||||
# lookup itself raised AttributeError, and the inner
|
||||
# ``except Exception: raise`` re-raised THAT - aborting
|
||||
# the whole build with a confusing secondary error
|
||||
# instead of the real one. We now record the reason,
|
||||
# log the actual exception together with the
|
||||
# will-executor it happened on, and simply move on to
|
||||
# the next one, which is what the original code was
|
||||
# clearly trying to do.
|
||||
self.last_build_error = "WILLEXECUTOR_TX_ERROR"
|
||||
_logger.error(
|
||||
f"build transactions: error preparing transactions: {e}"
|
||||
"build transactions: error preparing transactions "
|
||||
"for will-executor %s: %r",
|
||||
(willexecutor or {}).get("url", "(none)"),
|
||||
e,
|
||||
)
|
||||
try:
|
||||
if "w!ll3x3c" in e.heirname:
|
||||
Willexecutors.is_selected(
|
||||
e.heirname[len("w!ll3x3c") :], False
|
||||
)
|
||||
break
|
||||
except Exception:
|
||||
raise
|
||||
break
|
||||
total_fees = 0
|
||||
total_fees_real = 0
|
||||
total_in = 0
|
||||
@@ -746,12 +762,26 @@ class Heirs(dict, Logger):
|
||||
if i >= 10:
|
||||
break
|
||||
else:
|
||||
self.last_build_error = "NO_FUTURE_DATE"
|
||||
_logger.info(
|
||||
f"no locktimes for willexecutor {willexecutor} skipped"
|
||||
)
|
||||
break
|
||||
alltxs.update(txs)
|
||||
|
||||
# Every will-executor was skipped by the is_selected/is_valid filter
|
||||
# (or the list was empty) and no "no will-executor" build was allowed,
|
||||
# so the loop above never even attempted a build. This path used to
|
||||
# return silently with no log line at all, which is exactly the case
|
||||
# the owner hit: the dialog then blamed balance/dust/check-alive, none
|
||||
# of which was true.
|
||||
if not alltxs and processed_willexecutors == 0:
|
||||
self.last_build_error = "NO_WILLEXECUTOR_USABLE"
|
||||
_logger.info(
|
||||
"no usable will-executor: all %d skipped (not selected or not valid)",
|
||||
willexecutorslen,
|
||||
)
|
||||
|
||||
return alltxs
|
||||
|
||||
def get_transactions(
|
||||
|
||||
@@ -24,12 +24,13 @@ 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
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from electrum import constants, json_db
|
||||
from electrum.logging import get_logger
|
||||
from electrum.plugin import BasePlugin
|
||||
from electrum.transaction import tx_from_any
|
||||
from electrum.util import classproperty
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
@@ -108,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)."""
|
||||
@@ -169,9 +172,12 @@ class BalPlugin(BasePlugin):
|
||||
}
|
||||
|
||||
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||
chainname = (
|
||||
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||
)
|
||||
# Must be a classproperty (not a plain class attribute) because the class
|
||||
# is defined before constants.net is set to the correct network — a plain
|
||||
# attribute would capture "bitcoin" and never update.
|
||||
@classproperty
|
||||
def chainname(cls):
|
||||
return constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||
|
||||
# Default geometry hint for some dialogs (kept from the original code).
|
||||
SIZE = (159, 97)
|
||||
@@ -251,12 +257,37 @@ class BalPlugin(BasePlugin):
|
||||
# (handled by BalWindow.get_wallet_password). Default ON.
|
||||
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
|
||||
|
||||
# REBUILD_ON_CLOSE: when enabled (default), closing the wallet or
|
||||
# quitting Electrum runs the "Build your will" wizard
|
||||
# (BalBuildWillDialog) to rebuild and re-validate the will. When
|
||||
# disabled, on_close() only persists the current in-memory willitems to
|
||||
# the wallet DB: no rebuild dialog, no auto-sign/broadcast, no
|
||||
# invalidation prompts at close. Default ON.
|
||||
self.REBUILD_ON_CLOSE = BalConfig(config, "bal_rebuild_on_close", True)
|
||||
|
||||
# AUTO_REBUILD: when enabled, an incoming/outgoing wallet transaction
|
||||
# automatically re-runs the same rebuild flow the wizard runs at
|
||||
# wallet close (anticipate the delivery date by one day to orphan the
|
||||
# previous will; build an on-chain invalidation tx ONLY when the
|
||||
# anticipated locktime would fall before the check-alive threshold or
|
||||
# the threshold is already in the past). When disabled (default) the
|
||||
# will is only rebuilt when the user presses Check / Prepare or closes
|
||||
# the wallet. Default OFF.
|
||||
self.AUTO_REBUILD = BalConfig(config, "bal_auto_rebuild", False)
|
||||
|
||||
# EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
|
||||
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
|
||||
# not only inside the "Build your will" wizard. Default OFF, so the dates
|
||||
# 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
|
||||
@@ -438,8 +469,8 @@ class BalPlugin(BasePlugin):
|
||||
def default_will_settings_absolute():
|
||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
||||
relative_dates = BalPlugin.default_will_settings_relative()
|
||||
today = date.today()
|
||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||
today = datetime.now(tz=timezone.utc).date()
|
||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
||||
threshold = (
|
||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
||||
).timestamp()
|
||||
@@ -499,12 +530,12 @@ class BalTimestamp:
|
||||
"""
|
||||
int32_max = 2 ** 31 - 1
|
||||
try:
|
||||
return datetime.fromtimestamp(ts)
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
try:
|
||||
return datetime.fromtimestamp(min(int(ts), int32_max))
|
||||
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return datetime.fromtimestamp(int32_max)
|
||||
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
|
||||
|
||||
def to_date(self, from_date=None, reverse=False):
|
||||
"""Resolve to a ``datetime``.
|
||||
@@ -517,7 +548,7 @@ class BalTimestamp:
|
||||
return self._safe_fromtimestamp(self.value)
|
||||
else:
|
||||
if from_date is None:
|
||||
from_date = datetime.now()
|
||||
from_date = datetime.now(tz=timezone.utc)
|
||||
if isinstance(from_date, (int, float)):
|
||||
from_date = self._safe_fromtimestamp(from_date)
|
||||
reverse = 1 if not reverse else -1
|
||||
|
||||
212
bal/core/qrtransfer.py
Normal file
212
bal/core/qrtransfer.py
Normal file
@@ -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
|
||||
@@ -38,7 +38,7 @@ def compute_reminder_offsets(days, count):
|
||||
count: requested number of reminders.
|
||||
|
||||
Returns:
|
||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[30, 16, 1]`` for
|
||||
``days=30, count=3``. Empty if there is no room for any reminder.
|
||||
"""
|
||||
# No room for any reminder (deadline today or already passed).
|
||||
|
||||
102
bal/core/util.py
102
bal/core/util.py
@@ -18,11 +18,14 @@ original implementation.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
from datetime import datetime, timedelta
|
||||
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)."""
|
||||
@@ -103,7 +141,7 @@ class Util:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
now = datetime.now()
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if locktime[-1] == "y":
|
||||
locktime = str(int(locktime[:-1]) * 365) + "d"
|
||||
if locktime[-1] == "d":
|
||||
@@ -189,7 +227,7 @@ class Util:
|
||||
# moment, so fall back to the legacy forward-from-now resolution.
|
||||
return Util.parse_locktime_string(current)
|
||||
try:
|
||||
base = datetime.fromtimestamp(int(tx_locktime)).replace(
|
||||
base = datetime.fromtimestamp(int(tx_locktime), tz=timezone.utc).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
build_moment = base - timedelta(days=built_days)
|
||||
@@ -440,9 +478,9 @@ class Util:
|
||||
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
||||
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
||||
try:
|
||||
dt = datetime.fromtimestamp(locktime)
|
||||
dt = datetime.fromtimestamp(locktime, tz=timezone.utc)
|
||||
except (OverflowError, OSError, ValueError):
|
||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc)
|
||||
dt -= timedelta(seconds=seconds)
|
||||
out = dt.timestamp()
|
||||
|
||||
@@ -450,34 +488,6 @@ class Util:
|
||||
out = 1
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def cmp_locktime(locktimea, locktimeb):
|
||||
"""Compare two relative locktime strings sharing the same unit."""
|
||||
if locktimea == locktimeb:
|
||||
return 0
|
||||
strlocktimea = str(locktimea)
|
||||
strlocktimeb = str(locktimeb)
|
||||
if locktimea[-1] in "ydb":
|
||||
if locktimeb[-1] == locktimea[-1]:
|
||||
return int(strlocktimea[-1]) - int(strlocktimeb[-1])
|
||||
else:
|
||||
return int(locktimea) - (locktimeb)
|
||||
|
||||
@staticmethod
|
||||
def get_lowest_valid_tx(available_utxos, will):
|
||||
"""Placeholder kept from the original code (sorts the will by locktime)."""
|
||||
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
||||
for _txid, _willitem in will.items():
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_locktimes(will):
|
||||
"""Return the distinct locktimes used by the transactions in ``will``."""
|
||||
locktimes = {}
|
||||
for _, willitem in will.items():
|
||||
locktimes[willitem["tx"].locktime] = True
|
||||
return locktimes.keys()
|
||||
|
||||
@staticmethod
|
||||
def get_lowest_locktimes(locktimes):
|
||||
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
||||
@@ -492,32 +502,6 @@ class Util:
|
||||
|
||||
return sorted(sorted_timestamp), sorted(sorted_block)
|
||||
|
||||
@staticmethod
|
||||
def get_lowest_locktimes_from_will(will):
|
||||
"""Convenience wrapper: lowest locktimes directly from a will dict."""
|
||||
return Util.get_lowest_locktimes(Util.get_locktimes(will))
|
||||
|
||||
@staticmethod
|
||||
def search_willtx_per_io(will, tx):
|
||||
"""Find a will entry whose tx has the same inputs/outputs as ``tx``."""
|
||||
for wid, w in will.items():
|
||||
if Util.cmp_txs(w["tx"], tx["tx"]):
|
||||
return wid, w
|
||||
return None, None
|
||||
|
||||
@staticmethod
|
||||
def invalidate_will(will):
|
||||
raise Exception("not implemented")
|
||||
|
||||
@staticmethod
|
||||
def get_will_spent_utxos(will):
|
||||
"""Collect every input spent by any transaction in ``will``."""
|
||||
utxos = []
|
||||
for _, willitem in will.items():
|
||||
utxos += willitem["tx"].inputs()
|
||||
|
||||
return utxos
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# UTXO helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
125
bal/core/will.py
125
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
|
||||
@@ -74,11 +73,6 @@ class Will:
|
||||
if not will[child[0]].father:
|
||||
will[child[0]].father = willid
|
||||
|
||||
# return a list of will sorted by locktime
|
||||
@staticmethod
|
||||
def get_sorted_will(will):
|
||||
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
||||
|
||||
@staticmethod
|
||||
def only_valid(will):
|
||||
for k, v in will.items():
|
||||
@@ -107,15 +101,6 @@ class Will:
|
||||
and not w.get_status("CHECKED")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def search_equal_tx(will, tx, wid):
|
||||
for w in will:
|
||||
if w != wid and not tx.to_json() != will[w]["tx"].to_json():
|
||||
if will[w]["tx"].txid() != tx.txid():
|
||||
if Util.cmp_txs(will[w]["tx"], tx):
|
||||
return will[w]["tx"]
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_tx_from_any(x):
|
||||
try:
|
||||
@@ -157,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:
|
||||
@@ -179,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(
|
||||
@@ -479,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:
|
||||
@@ -516,6 +501,7 @@ class Will:
|
||||
for _wid, w in will.items():
|
||||
if w.get_status("VALID") and not w.get_status("COMPLETE"):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def search_rai(all_inputs, all_utxos, will, wallet):
|
||||
@@ -1340,47 +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()
|
||||
else:
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status", None)
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
self.tx_fees = w.get("baltx_fees", 0)
|
||||
self.sigs_required = int(w.get("sigs_required", 0))
|
||||
self.sigs_have = int(w.get("sigs_have", 0))
|
||||
self.father = w.get("Father", None)
|
||||
self.children = w.get("Children", None)
|
||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
for s in self.STATUS:
|
||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||
# Backward-compatibility migration (A2): the "PENDING" status was
|
||||
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
||||
# store the flag under the legacy "PENDING" key, so if that key is
|
||||
# present and set, carry it over to "MEMPOOL". This way no state is
|
||||
# lost when loading an older will. The new key always wins if both
|
||||
# happen to be present.
|
||||
if "MEMPOOL" not in w and w.get("PENDING"):
|
||||
self.STATUS["MEMPOOL"][1] = True
|
||||
if isinstance(w, WillItem):
|
||||
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
|
||||
# form and deserialize from there: the tx is re-parsed into a fresh
|
||||
# object, STATUS is rebuilt from the clones below and heirs /
|
||||
# will-executors are cloned recursively, so the copy shares no
|
||||
# mutable state with the source. See also copy().
|
||||
data = w.to_dict()
|
||||
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
|
||||
data["willexecutor"] = (
|
||||
copy_structure(w.we) if w.we is not None else None
|
||||
)
|
||||
if not _id:
|
||||
self._id = self.tx.txid()
|
||||
else:
|
||||
self._id = _id
|
||||
_id = w._id
|
||||
w = data
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status") or ""
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
self.tx_fees = w.get("baltx_fees", 0)
|
||||
self.sigs_required = int(w.get("sigs_required", 0))
|
||||
self.sigs_have = int(w.get("sigs_have", 0))
|
||||
self.father = w.get("Father", None)
|
||||
self.children = w.get("Children", None)
|
||||
self.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||
for s in self.STATUS:
|
||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||
# Backward-compatibility migration (A2): the "PENDING" status was
|
||||
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
||||
# store the flag under the legacy "PENDING" key, so if that key is
|
||||
# present and set, carry it over to "MEMPOOL". This way no state is
|
||||
# lost when loading an older will. The new key always wins if both
|
||||
# happen to be present.
|
||||
if "MEMPOOL" not in w and w.get("PENDING"):
|
||||
self.STATUS["MEMPOOL"][1] = True
|
||||
if not _id:
|
||||
self._id = self.tx.txid()
|
||||
else:
|
||||
self._id = _id
|
||||
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
if not self._id:
|
||||
self.status += "ERROR!!!"
|
||||
self.valid = False
|
||||
|
||||
if wallet:
|
||||
self.tx.add_info_from_wallet(wallet)
|
||||
|
||||
def copy(self, wallet=None):
|
||||
"""Return an independent copy of this WillItem (no deepcopy).
|
||||
|
||||
The copy is produced by serializing this item and deserializing it:
|
||||
the transaction is re-parsed, the STATUS table is rebuilt and
|
||||
heirs / will-executors are cloned recursively, so the result shares no
|
||||
mutable state with ``self``. Pass a ``wallet`` when the copy's tx
|
||||
needs its address/value information restored
|
||||
(``tx.add_info_from_wallet``).
|
||||
"""
|
||||
return WillItem(self, _id=self._id, wallet=wallet)
|
||||
|
||||
@staticmethod
|
||||
def copy_status_table(status_table):
|
||||
"""Clone a STATUS table (``{flag: [label, bool]}``) without deepcopy.
|
||||
|
||||
Both the outer dict and every inner ``[label, bool]`` list are new
|
||||
objects, so mutating the returned table never affects the source.
|
||||
"""
|
||||
return {k: [label, value] for k, (label, value) in status_table.items()}
|
||||
|
||||
def to_dict(self):
|
||||
out = {
|
||||
"_id": self._id,
|
||||
@@ -1394,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:
|
||||
|
||||
@@ -112,8 +112,6 @@ def is_tor_active():
|
||||
return False
|
||||
|
||||
|
||||
chainname = BalPlugin.chainname
|
||||
|
||||
|
||||
class Willexecutors:
|
||||
|
||||
@@ -146,9 +144,9 @@ class Willexecutors:
|
||||
|
||||
@staticmethod
|
||||
def save(bal_plugin, willexecutors):
|
||||
_logger.debug(f"save {willexecutors},{chainname}")
|
||||
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
|
||||
aw = bal_plugin.WILLEXECUTORS.get()
|
||||
aw[chainname] = willexecutors
|
||||
aw[BalPlugin.chainname] = willexecutors
|
||||
bal_plugin.WILLEXECUTORS.set(aw)
|
||||
_logger.debug(f"saved: {aw}")
|
||||
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||
@@ -158,7 +156,7 @@ class Willexecutors:
|
||||
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
|
||||
):
|
||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||
willexecutors = willexecutors.get(chainname, {})
|
||||
willexecutors = willexecutors.get(BalPlugin.chainname, {})
|
||||
to_del = []
|
||||
for w in willexecutors:
|
||||
if not isinstance(willexecutors[w], dict):
|
||||
@@ -172,7 +170,7 @@ class Willexecutors:
|
||||
)
|
||||
)
|
||||
del willexecutors[w]
|
||||
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
|
||||
bal = bal_plugin.WILLEXECUTORS.default.get(BalPlugin.chainname, {})
|
||||
for bal_url, bal_executor in bal.items():
|
||||
if bal_url not in willexecutors:
|
||||
_logger.debug(f"force add {bal_url} willexecutor")
|
||||
@@ -368,7 +366,7 @@ class Willexecutors:
|
||||
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||
if w := Willexecutors.send_request(
|
||||
"post",
|
||||
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
||||
willexecutor["url"] + "/" + BalPlugin.chainname + "/pushtxs",
|
||||
data=willexecutor["txs"].encode("ascii"),
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
@@ -408,7 +406,7 @@ class Willexecutors:
|
||||
# single short timeout instead of retrying 10x with sleeps, which
|
||||
# used to freeze the UI for minutes per unreachable server.
|
||||
w = Willexecutors.send_request(
|
||||
"get", url + "/" + chainname + "/info",
|
||||
"get", url + "/" + BalPlugin.chainname + "/info",
|
||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||
)
|
||||
if isinstance(w, dict):
|
||||
@@ -788,7 +786,7 @@ class Willexecutors:
|
||||
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
||||
willexecutors = Willexecutors.send_request(
|
||||
"get",
|
||||
f"{welist_server}data/{chainname}?page=0&limit=100",
|
||||
f"{welist_server}data/{BalPlugin.chainname}?page=0&limit=100",
|
||||
)
|
||||
if not isinstance(willexecutors, dict):
|
||||
_logger.warning(
|
||||
|
||||
@@ -13,12 +13,15 @@ The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
||||
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 QToolButton
|
||||
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||
|
||||
from ...core.reminders import write_temp_ics
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import _, _logger
|
||||
|
||||
|
||||
class BalCalendarButton(QToolButton):
|
||||
|
||||
@@ -15,7 +15,6 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import os
|
||||
import subprocess
|
||||
@@ -28,6 +27,7 @@ from functools import partial
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||
from electrum.gui.common_qt.util import draw_qr
|
||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||
from electrum.gui.qt.my_treeview import MyTreeView
|
||||
@@ -42,6 +42,7 @@ from electrum.gui.qt.util import (
|
||||
MessageBoxMixin,
|
||||
OkButton,
|
||||
TaskThread,
|
||||
WaitingDialog,
|
||||
WindowModalDialog,
|
||||
char_width_in_lineedit,
|
||||
getOpenFileName,
|
||||
@@ -80,6 +81,7 @@ from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QAbstractSpinBox,
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDateTimeEdit,
|
||||
@@ -92,6 +94,7 @@ from PyQt6.QtWidgets import (
|
||||
QMenu,
|
||||
QMenuBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
@@ -108,6 +111,7 @@ from ...core.heirs import (
|
||||
HEIR_DUST_AMOUNT,
|
||||
HEIR_REAL_AMOUNT,
|
||||
OP_RETURN_PREFIX,
|
||||
BalanceTooLowException,
|
||||
HeirAmountIsDustException,
|
||||
Heirs,
|
||||
WillExecutorFeeTooHighException,
|
||||
@@ -118,7 +122,7 @@ from ...core.heirs import (
|
||||
|
||||
# --- Core (GUI-free) logic layer ---
|
||||
from ...core.plugin_base import BalPlugin, BalTimestamp
|
||||
from ...core.util import Util
|
||||
from ...core.util import Util, copy_structure
|
||||
from ...core.will import (
|
||||
AmountException,
|
||||
HeirChangeException,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,8 +19,59 @@ from typing import TYPE_CHECKING
|
||||
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import (
|
||||
OP_RETURN_PREFIX,
|
||||
BalTimestamp,
|
||||
Buttons,
|
||||
CancelButton,
|
||||
HelpButton,
|
||||
MessageBoxMixin,
|
||||
MyTreeView,
|
||||
OkButton,
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QColor,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QModelIndex,
|
||||
QPersistentModelIndex,
|
||||
QPushButton,
|
||||
QSize,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStandardItem,
|
||||
QStandardItemModel,
|
||||
Qt,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
TaskThread,
|
||||
Util,
|
||||
Will,
|
||||
Willexecutors,
|
||||
WillItem,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
enum,
|
||||
export_meta_gui,
|
||||
getOpenFileName,
|
||||
import_meta_gui,
|
||||
is_op_return_address,
|
||||
partial,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
server_status_text,
|
||||
server_status_tooltip,
|
||||
signature_suffix,
|
||||
status_color,
|
||||
tx_from_any,
|
||||
write_json_file,
|
||||
)
|
||||
from .dialogs import BalBuildWillDialog, BalDialog
|
||||
from .widgets import BalCheckBox, WillSettingsWidget
|
||||
|
||||
@@ -612,11 +663,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
menu.addAction(_("Prepare"), self.build_transactions)
|
||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||
export_menu = menu.addMenu(_("Export"))
|
||||
export_menu.addAction(_("All"), self.export_will)
|
||||
export_menu.addAction(_("Valid"), self.export_will_valid)
|
||||
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
|
||||
menu.addAction(_("Import"), self.import_will_into_details)
|
||||
# Export/Import open a single window that offers all transports
|
||||
# (file / QR / audio). The Choose Filter / transport settings live
|
||||
# inside that window.
|
||||
menu.addAction(_("Export"), self.export_will)
|
||||
menu.addAction(_("Import"), self.import_will)
|
||||
menu.addAction(_("Merge"), self.merge_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
@@ -682,38 +733,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if will:
|
||||
self.update_will(will)
|
||||
|
||||
def export_json_file(self, path):
|
||||
write_json_file(path, self.will)
|
||||
|
||||
def export_will(self):
|
||||
self.bal_window.export_will()
|
||||
self.update()
|
||||
self.bal_window.export_will_dialog()
|
||||
|
||||
def export_will_valid(self):
|
||||
"""Export only the will items that are valid."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
|
||||
def export_will_valid_incomplete(self):
|
||||
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
|
||||
subset = {
|
||||
wid: wi
|
||||
for wid, wi in self.will.items()
|
||||
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
|
||||
}
|
||||
if not subset:
|
||||
self.show_message(_("No valid, incomplete will item to export"))
|
||||
return
|
||||
self.bal_window.export_will(will=subset)
|
||||
self.update()
|
||||
def import_will(self):
|
||||
self.bal_window.import_will_dialog()
|
||||
|
||||
def import_will_into_details(self):
|
||||
self.bal_window.import_will_into_details()
|
||||
|
||||
@@ -15,13 +15,36 @@ and cached in ``self.bal_windows``.
|
||||
"""
|
||||
|
||||
from electrum.gui.qt.main_window import StatusBarButton
|
||||
from electrum.plugin import hook
|
||||
from electrum.util import EventListener, event_listener
|
||||
from PyQt6.QtWidgets import QLayout
|
||||
|
||||
from .common import *
|
||||
from .common import ( # underscore names are not re-exported by "import *"
|
||||
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
|
||||
from .common import (
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
EnterButton,
|
||||
HelpButton,
|
||||
PasswordDialog,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
UserCancelled,
|
||||
Willexecutors,
|
||||
_,
|
||||
_logger,
|
||||
add_widget,
|
||||
partial,
|
||||
read_QIcon_from_bytes,
|
||||
read_QPixmap_from_bytes,
|
||||
show_modal,
|
||||
webopen,
|
||||
)
|
||||
from .dialogs import BalDialog
|
||||
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
||||
@@ -40,7 +63,7 @@ def _window_key(window):
|
||||
return id(window)
|
||||
|
||||
|
||||
class Plugin(BalPlugin):
|
||||
class Plugin(BalPlugin, EventListener):
|
||||
def __init__(self, parent, config, name):
|
||||
_logger.info("INIT BALPLUGIN")
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
@@ -49,6 +72,10 @@ class Plugin(BalPlugin):
|
||||
# remove a stale button before creating a fresh one when a wallet is
|
||||
# switched / Electrum is restarted, so the icon is never duplicated.
|
||||
self._statusbar_buttons = {}
|
||||
# Register the on_event_* handlers with Electrum's callback manager so
|
||||
# the plugin learns about new wallet transactions (used by the
|
||||
# AUTO_REBUILD setting).
|
||||
self.register_callbacks()
|
||||
|
||||
@hook
|
||||
def init_qt(self, gui_object):
|
||||
@@ -328,6 +355,44 @@ class Plugin(BalPlugin):
|
||||
except Exception as e:
|
||||
_logger.error("close_wallet: on_close failed: {}".format(e))
|
||||
|
||||
@event_listener
|
||||
def on_event_new_transaction(self, wallet, tx):
|
||||
"""Electrum event: a transaction was added to *wallet*."""
|
||||
self._wallet_activity(wallet)
|
||||
|
||||
@event_listener
|
||||
def on_event_wallet_updated(self, wallet):
|
||||
"""Electrum event: *wallet* finished a sync pass."""
|
||||
self._wallet_activity(wallet)
|
||||
|
||||
def _wallet_activity(self, wallet):
|
||||
"""React to wallet activity (new transaction / sync update).
|
||||
|
||||
When the AUTO_REBUILD setting is enabled, any change to a wallet that
|
||||
has a live BalWindow schedules the headless "auto rebuild" flow
|
||||
(``BalWindow.schedule_auto_rebuild``): it re-runs the same check the
|
||||
wizard runs at wallet close, anticipating the delivery date by one day
|
||||
and building an on-chain invalidation tx only when the anticipated
|
||||
locktime would fall before the check-alive threshold (or the threshold
|
||||
is already in the past).
|
||||
|
||||
This handler runs on the asyncio callback thread, so it only touches
|
||||
thread-safe state and defers all work to the BalWindow (which marshals
|
||||
itself onto the GUI thread through QTimer).
|
||||
"""
|
||||
if not self.AUTO_REBUILD.get():
|
||||
return
|
||||
for win in list(self.bal_windows.values()):
|
||||
try:
|
||||
if (
|
||||
getattr(win, "wallet", None) == wallet
|
||||
and win.ok
|
||||
and not win.disable_plugin
|
||||
):
|
||||
win.schedule_auto_rebuild()
|
||||
except Exception as e:
|
||||
_logger.debug("_wallet_activity failed: {}".format(e))
|
||||
|
||||
@hook
|
||||
def init_keystore(self):
|
||||
_logger.debug("init keystore")
|
||||
@@ -448,13 +513,39 @@ class Plugin(BalPlugin):
|
||||
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
||||
)
|
||||
|
||||
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
|
||||
# config (default ON, see plugin_base.py), the SAME config used by the
|
||||
# checkbox inside the "Build your will" wizard's will-executor download
|
||||
# window, so the two stay in sync automatically. When enabled the plugin
|
||||
# also builds a will that does not require a will-executor (e.g. it can
|
||||
# be saved on a USB stick and a copy given to the heirs).
|
||||
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
|
||||
# "Rebuild will on wallet close" checkbox. Bound to the persisted
|
||||
# REBUILD_ON_CLOSE config (default ON). When ticked, closing the wallet
|
||||
# / quitting Electrum runs the "Build your will" wizard to rebuild and
|
||||
# re-validate the will. When unticked, the will is only rebuilt when
|
||||
# the user presses Check/Prepare. Visible to all users (BASIC and
|
||||
# ADVANCED).
|
||||
heir_rebuild_on_close = BalCheckBox(self.REBUILD_ON_CLOSE)
|
||||
|
||||
# "Rebuild automatically on new transactions" checkbox. Bound to the
|
||||
# persisted AUTO_REBUILD config (default OFF). When ticked, an incoming
|
||||
# or outgoing wallet transaction automatically re-runs the same rebuild
|
||||
# flow the wizard runs at wallet close: the delivery date is
|
||||
# anticipated by one day (so the new will replaces the previous one
|
||||
# without an invalidation tx), and an on-chain invalidation is only
|
||||
# built when the anticipated locktime would fall before the Check Alive
|
||||
# threshold or the threshold is already in the past. Visible to all
|
||||
# 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:
|
||||
@@ -572,6 +663,10 @@ class Plugin(BalPlugin):
|
||||
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
|
||||
|
||||
@@ -642,35 +737,13 @@ class Plugin(BalPlugin):
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
|
||||
# "Add transaction without will-executor" setting (formerly labelled
|
||||
# "No will-executor TX"). When ON the plugin ALSO builds the backup
|
||||
# inheritance transaction that does NOT require a will-executor (the
|
||||
# "celeste"/light-blue one shown in the will list): it can be saved on a
|
||||
# USB stick and a copy handed to the heirs. When OFF only the
|
||||
# transactions destined to the selected will-executors are built.
|
||||
#
|
||||
# Placed here (row 5, right below "Panel editable Date and Fee" and above
|
||||
# "Number of reminders") at the user's request so related options sit
|
||||
# together. The remaining grid rows below were renumbered accordingly.
|
||||
add_widget(
|
||||
grid,
|
||||
"Add transaction without willexecutor",
|
||||
heir_no_willexecutor,
|
||||
4,
|
||||
(
|
||||
"Create a will that does not require a Will-executor; it can be "
|
||||
"saved, for example, on a USB stick, and a copy can be given to "
|
||||
"the heirs."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
|
||||
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
|
||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||
add_widget(
|
||||
grid,
|
||||
"Max Will-Executor Fee (satoshi)",
|
||||
heir_max_willexecutor_fee,
|
||||
5,
|
||||
4,
|
||||
(
|
||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||
"will-executor. If a will-executor charges more than this, "
|
||||
@@ -678,14 +751,14 @@ class Plugin(BalPlugin):
|
||||
"Default: 500,000 satoshi (0.005 BTC)."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 5, 3)
|
||||
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 4, 3)
|
||||
# User Type selector placed BEFORE the advanced-only settings so the
|
||||
# user chooses basic/advanced first, then sees the relevant options.
|
||||
add_widget(
|
||||
grid,
|
||||
"User Type",
|
||||
user_type_combo,
|
||||
6,
|
||||
5,
|
||||
(
|
||||
"Choose how much detail the plugin shows.\n\n"
|
||||
"BASIC: simplified interface, safe configuration for most "
|
||||
@@ -696,7 +769,7 @@ class Plugin(BalPlugin):
|
||||
"editable."
|
||||
),
|
||||
)
|
||||
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3)
|
||||
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 3)
|
||||
# Number of reminders, event summary and event description are visible
|
||||
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
||||
# used and these settings are hidden.
|
||||
@@ -705,11 +778,11 @@ class Plugin(BalPlugin):
|
||||
"How many reminder alarms the exported calendar (.ics) event "
|
||||
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 7, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
|
||||
grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
||||
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_6), 7, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
|
||||
|
||||
lbl_event_summary = QLabel(_("Event summary"))
|
||||
help_event_summary = HelpButton(
|
||||
@@ -719,11 +792,11 @@ class Plugin(BalPlugin):
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
||||
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_7), 8, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
|
||||
|
||||
lbl_event_description = QLabel(_("Event description"))
|
||||
help_event_description = HelpButton(
|
||||
@@ -733,11 +806,11 @@ class Plugin(BalPlugin):
|
||||
" $heirs_complete: list of heirs name,address,amount\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
||||
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_8), 9, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3)
|
||||
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
|
||||
# factory default is always used and the setting is hidden.
|
||||
lbl_welist_server = QLabel(_("Welist Server URL"))
|
||||
@@ -745,11 +818,11 @@ class Plugin(BalPlugin):
|
||||
"URL of the server that provides the will-executor list. "
|
||||
"Only available in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
|
||||
grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
||||
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_9), 10, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
|
||||
|
||||
lbl_calendar_app = QLabel(_("Calendar app command"))
|
||||
help_calendar_app = HelpButton(
|
||||
@@ -757,11 +830,11 @@ class Plugin(BalPlugin):
|
||||
"Leave empty to use the system default (xdg-open/open/start).\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
|
||||
grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
|
||||
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3)
|
||||
|
||||
# Save-in-history toggle and history label: advanced-only rows. The
|
||||
# label field is disabled while the checkbox is off (see
|
||||
@@ -774,11 +847,11 @@ class Plugin(BalPlugin):
|
||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
|
||||
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
|
||||
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
|
||||
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
|
||||
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_11), 12, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
|
||||
|
||||
lbl_history_label = QLabel(_("History label"))
|
||||
help_history_label = HelpButton(
|
||||
@@ -788,11 +861,11 @@ class Plugin(BalPlugin):
|
||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||
"Only used in ADVANCED mode."
|
||||
)
|
||||
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
|
||||
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
|
||||
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
|
||||
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
|
||||
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
|
||||
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
|
||||
grid.addWidget(_hide_if_basic(reset_btn_12), 13, 3)
|
||||
grid.addWidget(_hide_if_basic(reset_btn_12), 12, 3)
|
||||
|
||||
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
|
||||
# correct initial visibility inline (via _hide_if_basic) BEFORE being
|
||||
@@ -801,15 +874,76 @@ class Plugin(BalPlugin):
|
||||
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
||||
# setVisible() loop here.
|
||||
|
||||
grid.addWidget(heir_repush, 14, 0)
|
||||
grid.addWidget(heir_repush, 13, 0)
|
||||
grid.addWidget(
|
||||
HelpButton(
|
||||
"Broadcast all transactions to willexecutors including those already pushed"
|
||||
),
|
||||
14,
|
||||
13,
|
||||
2,
|
||||
)
|
||||
|
||||
# "Rebuild will on wallet close" row (always visible, BASIC + ADVANCED).
|
||||
# Placed below the rebroadcast button so the existing rows keep their
|
||||
# numbers.
|
||||
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
|
||||
help_rebuild_on_close = HelpButton(
|
||||
"Run the 'Build your will' wizard every time the wallet is closed "
|
||||
"or Electrum is quit, so the will is rebuilt and re-validated.\n"
|
||||
"When disabled, the will is only rebuilt when you press Check or "
|
||||
"Prepare. The last built state is still saved to the wallet."
|
||||
)
|
||||
grid.addWidget(lbl_rebuild_on_close, 14, 0)
|
||||
grid.addWidget(heir_rebuild_on_close, 14, 1)
|
||||
grid.addWidget(help_rebuild_on_close, 14, 2)
|
||||
reset_btn_rebuild_on_close = _make_reset_btn(
|
||||
self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"
|
||||
)
|
||||
grid.addWidget(reset_btn_rebuild_on_close, 14, 3)
|
||||
|
||||
# "Rebuild automatically on new transactions" row (always visible,
|
||||
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
|
||||
# row.
|
||||
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
|
||||
help_auto_rebuild = HelpButton(
|
||||
"When a new transaction arrives for the wallet, automatically "
|
||||
"rebuild the will the same way the wizard does at wallet close: "
|
||||
"the delivery date is anticipated by one day so the new will "
|
||||
"replaces the previous one, and the rebuilt transactions are "
|
||||
"signed and sent to their will-executors.\n"
|
||||
"An on-chain invalidation transaction is only built when the "
|
||||
"anticipated delivery date would fall before the Check Alive "
|
||||
"threshold, or when the threshold is already in the past.\n"
|
||||
"When disabled (default), the will is only rebuilt on Check / "
|
||||
"Prepare / wallet close."
|
||||
)
|
||||
grid.addWidget(lbl_auto_rebuild, 15, 0)
|
||||
grid.addWidget(heir_auto_rebuild, 15, 1)
|
||||
grid.addWidget(help_auto_rebuild, 15, 2)
|
||||
reset_btn_auto_rebuild = _make_reset_btn(
|
||||
self.AUTO_REBUILD, heir_auto_rebuild, "check"
|
||||
)
|
||||
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 #
|
||||
@@ -834,7 +968,6 @@ class Plugin(BalPlugin):
|
||||
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
||||
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
||||
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
|
||||
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
|
||||
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
|
||||
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
||||
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
||||
@@ -842,6 +975,9 @@ class Plugin(BalPlugin):
|
||||
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
||||
(self.SAVE_HISTORY, heir_save_history, "check"),
|
||||
(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.
|
||||
@@ -862,6 +998,10 @@ class Plugin(BalPlugin):
|
||||
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.
|
||||
|
||||
@@ -28,8 +28,53 @@ from ...core.input_rules import (
|
||||
)
|
||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||
from .calendar import BalCalendar, BalCalendarButton
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import (
|
||||
DECIMAL_POINT,
|
||||
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||
NLOCKTIME_MAX,
|
||||
Any,
|
||||
BalTimestamp,
|
||||
BTCAmountEdit,
|
||||
ColorScheme,
|
||||
Decimal,
|
||||
HelpButton,
|
||||
Optional,
|
||||
QAbstractSpinBox,
|
||||
QCheckBox,
|
||||
QColor,
|
||||
QComboBox,
|
||||
QDateTime,
|
||||
QDateTimeEdit,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPainter,
|
||||
QPalette,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionFrame,
|
||||
Qt,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
Union,
|
||||
Util,
|
||||
Will,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
datetime,
|
||||
getSaveFileName,
|
||||
log_error,
|
||||
os,
|
||||
partial,
|
||||
pyqtSignal,
|
||||
read_QIcon_from_bytes,
|
||||
signature_suffix,
|
||||
status_color,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .window import BalWindow
|
||||
|
||||
@@ -24,8 +24,64 @@ from ...core.checkalive import (
|
||||
check_alive_expired,
|
||||
resolve_date_to_check,
|
||||
)
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import (
|
||||
OP_RETURN_PREFIX,
|
||||
AmountException,
|
||||
BalPlugin,
|
||||
Buttons,
|
||||
CancelButton,
|
||||
ElectrumWindow,
|
||||
FileImportFailed,
|
||||
HeirChangeException,
|
||||
HeirNotFoundException,
|
||||
Heirs,
|
||||
HelpButton,
|
||||
Mapping,
|
||||
Network,
|
||||
NoHeirsException,
|
||||
NotCompleteWillException,
|
||||
NoWillExecutorNotPresent,
|
||||
OkButton,
|
||||
Optional,
|
||||
PaymentIdentifier,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
SerializationError,
|
||||
Transaction,
|
||||
TxDialog,
|
||||
TxFeesChangedException,
|
||||
Util,
|
||||
Will,
|
||||
WillexecutorChangeException,
|
||||
WillExecutorFeeTooHighException,
|
||||
WillExecutorNotPresent,
|
||||
Willexecutors,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
_,
|
||||
_logger,
|
||||
char_width_in_lineedit,
|
||||
copy_structure,
|
||||
export_meta_gui,
|
||||
import_meta_gui,
|
||||
is_onion_url,
|
||||
is_op_return_address,
|
||||
is_tor_active,
|
||||
log_error,
|
||||
partial,
|
||||
read_json_file,
|
||||
read_QIcon_from_bytes,
|
||||
show_on_top,
|
||||
shown_cv,
|
||||
time,
|
||||
tx_from_any,
|
||||
write_json_file,
|
||||
)
|
||||
from .dialogs import (
|
||||
BalBuildWillDialog,
|
||||
BalDialog,
|
||||
@@ -33,12 +89,23 @@ from .dialogs import (
|
||||
BalWizardDialog,
|
||||
WillDetailDialog,
|
||||
WillExecutorDialog,
|
||||
WillExportDialog,
|
||||
WillImportDialog,
|
||||
_complete_import,
|
||||
decode_will_payload,
|
||||
)
|
||||
from .lists import HeirListWidget, PreviewList
|
||||
from .widgets import LockTimeWidget, PercAmountEdit
|
||||
|
||||
|
||||
class BalWindow:
|
||||
# Automatic rebuild-on-new-transaction flow (AUTO_REBUILD setting):
|
||||
# the debounce window collapses bursts of wallet events into one run, and
|
||||
# the cooldown prevents the flow from re-triggering right after a rebuild
|
||||
# (the freshly persisted txs can themselves fire wallet events).
|
||||
_AUTO_REBUILD_DEBOUNCE_MS = 5000
|
||||
_AUTO_REBUILD_COOLDOWN = 10.0
|
||||
|
||||
def __init__(self, bal_plugin: "BalPlugin", window: "ElectrumWindow"):
|
||||
self.bal_plugin = bal_plugin
|
||||
self.window = window
|
||||
@@ -56,6 +123,9 @@ class BalWindow:
|
||||
# ``init_menubar_tools`` twice would add the Heirs/Will tabs and the
|
||||
# menu actions twice, producing the garbled/condensed menu entry.
|
||||
self._menubar_initialized = False
|
||||
# Auto-rebuild flow state: re-entrancy guard and cooldown deadline.
|
||||
self._auto_rebuild_running = False
|
||||
self._auto_rebuild_cooldown_until = 0.0
|
||||
self.bal_plugin.get_decimal_point = self.window.get_decimal_point
|
||||
|
||||
if self.window.wallet:
|
||||
@@ -450,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)
|
||||
@@ -915,6 +985,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(
|
||||
@@ -950,75 +1027,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
|
||||
@@ -1034,6 +1129,297 @@ class BalWindow:
|
||||
password = self.get_wallet_password(message)
|
||||
return password
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Automatic rebuild on new transactions (AUTO_REBUILD)
|
||||
#
|
||||
# When the AUTO_REBUILD setting is enabled, wallet activity (a new
|
||||
# transaction / a sync update) schedules the headless rebuild flow below,
|
||||
# which reproduces EXACTLY what the "Build your will" wizard does at wallet
|
||||
# close (task_phase1 / task_phase2):
|
||||
#
|
||||
# * the delivery date of the rebuilt transactions is anticipated by one
|
||||
# day (Will.search_anticipate -> check_anticipate) so the new will
|
||||
# mines BEFORE the previous one and orphans it WITHOUT an on-chain
|
||||
# invalidation transaction;
|
||||
# * an on-chain invalidation transaction is built ONLY when the
|
||||
# anticipated locktime would fall before the check-alive threshold
|
||||
# (post-build check_will -> WillExpiredException), or when the
|
||||
# threshold is already in the past (CheckAliveError) - the same two
|
||||
# conditions that trigger invalidation in the wizard.
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def schedule_auto_rebuild(self, delay_ms=None):
|
||||
"""Debounced entry point for the auto-rebuild flow.
|
||||
|
||||
Called by ``Plugin._wallet_activity`` (on the asyncio callback thread)
|
||||
whenever a transaction/update is seen for this wallet. The actual
|
||||
rebuild is deferred through ``QTimer`` (thread-safe to schedule, runs
|
||||
on the GUI thread) so a burst of events collapses into a single run.
|
||||
"""
|
||||
try:
|
||||
delay = delay_ms if delay_ms is not None else self._AUTO_REBUILD_DEBOUNCE_MS
|
||||
QTimer.singleShot(delay, self._run_auto_rebuild)
|
||||
except Exception as e:
|
||||
_logger.debug("schedule_auto_rebuild failed: {}".format(e))
|
||||
|
||||
def _run_auto_rebuild(self):
|
||||
"""GUI-thread guard before launching the auto-rebuild worker.
|
||||
|
||||
Checks the cheap guards that must be evaluated on the GUI thread and,
|
||||
when allowed, runs the headless flow in a background thread so the
|
||||
interface is not frozen (signing/pushing can take a while).
|
||||
"""
|
||||
if not self._auto_rebuild_allowed():
|
||||
return
|
||||
self._auto_rebuild_running = True
|
||||
threading.Thread(target=self._auto_rebuild_worker, daemon=True).start()
|
||||
|
||||
def _auto_rebuild_worker(self):
|
||||
try:
|
||||
self._auto_rebuild_flow()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild worker failed: {}".format(e))
|
||||
finally:
|
||||
self._auto_rebuild_running = False
|
||||
QTimer.singleShot(0, self._after_auto_rebuild)
|
||||
|
||||
def _auto_rebuild_allowed(self):
|
||||
"""Cheap guards evaluated before running the auto-rebuild flow."""
|
||||
if self.disable_plugin or not self.ok:
|
||||
return False
|
||||
if not self.bal_plugin.AUTO_REBUILD.get():
|
||||
return False
|
||||
if not self.willitems:
|
||||
return False
|
||||
if self._auto_rebuild_running:
|
||||
return False
|
||||
if time.time() < self._auto_rebuild_cooldown_until:
|
||||
return False
|
||||
return True
|
||||
|
||||
def maybe_auto_rebuild(self):
|
||||
"""Run the headless auto-rebuild flow synchronously on this thread.
|
||||
|
||||
This is the testable entry point (and what the background worker
|
||||
runs): it reproduces the wizard's close-time flow and returns True when
|
||||
it rebuilt/invalidated the will, False when there was nothing to do.
|
||||
"""
|
||||
if not self._auto_rebuild_allowed():
|
||||
return False
|
||||
self._auto_rebuild_running = True
|
||||
try:
|
||||
result = self._auto_rebuild_flow()
|
||||
finally:
|
||||
self._auto_rebuild_running = False
|
||||
QTimer.singleShot(0, self._after_auto_rebuild)
|
||||
return result
|
||||
|
||||
def _after_auto_rebuild(self):
|
||||
"""Refresh the will tabs after an auto-rebuild (GUI thread)."""
|
||||
try:
|
||||
self.update_all()
|
||||
except Exception as e:
|
||||
_logger.debug("_after_auto_rebuild update_all failed: {}".format(e))
|
||||
try:
|
||||
if hasattr(self, "will_list_widget"):
|
||||
self.will_list_widget.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _auto_rebuild_flow(self):
|
||||
"""Core headless rebuild flow (mirrors the wizard's close flow).
|
||||
|
||||
Returns True when the will was rebuilt or invalidated, False when there
|
||||
was nothing to do. Runs on the caller's thread.
|
||||
"""
|
||||
try:
|
||||
self._auto_rebuild_cooldown_until = (
|
||||
time.time() + self._AUTO_REBUILD_COOLDOWN
|
||||
)
|
||||
_logger.info("auto rebuild: checking will after wallet activity")
|
||||
|
||||
# 1) Recompute date_to_check / willexecutors exactly like
|
||||
# init_class_variables does at the start of the wizard's phase 1.
|
||||
# A Check Alive threshold already in the past (ADVANCED mode)
|
||||
# means the old will must be invalidated on-chain.
|
||||
try:
|
||||
self.init_class_variables()
|
||||
except CheckAliveError:
|
||||
_logger.info("auto rebuild: check-alive threshold passed -> invalidate")
|
||||
self._auto_invalidate_will()
|
||||
return True
|
||||
except NoHeirsException:
|
||||
_logger.info("auto rebuild: no heirs, nothing to rebuild")
|
||||
return False
|
||||
|
||||
# 2) Check the current will against the freshly computed reference
|
||||
# date. A still-valid will needs no rebuild.
|
||||
try:
|
||||
self.check_will()
|
||||
_logger.debug("auto rebuild: will is still valid, nothing to do")
|
||||
return False
|
||||
except (WillExpiredException, WillPostponedException) as e:
|
||||
# Expired ("too late to anticipate") or a postpone on a
|
||||
# signed/sent will: the old coins must be invalidated on-chain
|
||||
# first.
|
||||
_logger.info(
|
||||
"auto rebuild: {} -> invalidate".format(type(e).__name__)
|
||||
)
|
||||
self._auto_invalidate_will()
|
||||
return True
|
||||
except NoHeirsException:
|
||||
return False
|
||||
except NotCompleteWillException:
|
||||
# The will no longer covers the wallet's current UTXOs / heirs
|
||||
# / date: rebuild it. The rebuild automatically anticipates
|
||||
# the delivery date by one day when the same coins/heirs are
|
||||
# involved (Will.search_anticipate), so the new transactions
|
||||
# mine before the previous ones.
|
||||
pass
|
||||
|
||||
# 3) Rebuild.
|
||||
try:
|
||||
txs = self.build_will()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: build_will failed: {}".format(e))
|
||||
return False
|
||||
if not txs:
|
||||
_logger.info("auto rebuild: nothing was built")
|
||||
return False
|
||||
|
||||
# 4) Re-validate the freshly built will (mirrors task_phase1 after
|
||||
# build_will). If the anticipated locktime now falls before the
|
||||
# check-alive threshold, the previous will must be invalidated
|
||||
# on-chain before the new one is used - and we STOP, exactly like
|
||||
# the wizard ("invalidate_classic"): signing/pushing the new will
|
||||
# while the invalidation is not confirmed would race it for the
|
||||
# same inputs. The next wallet event / manual Check continues
|
||||
# once the invalidation confirms.
|
||||
try:
|
||||
self.check_will()
|
||||
except (WillExpiredException, WillPostponedException) as e:
|
||||
_logger.info(
|
||||
"auto rebuild: anticipated locktime crossed threshold "
|
||||
"({}) -> invalidate old will".format(type(e).__name__)
|
||||
)
|
||||
self._auto_invalidate_will()
|
||||
return True
|
||||
except NoHeirsException:
|
||||
return False
|
||||
except NotCompleteWillException:
|
||||
# The freshly rebuilt transactions simply need signing.
|
||||
pass
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"auto rebuild: post-build check failed: {}".format(e)
|
||||
)
|
||||
return False
|
||||
|
||||
# 5) Sign (passwordless wallets only, headlessly), persist and push
|
||||
# the rebuilt transactions to their will-executors: pushing the
|
||||
# earlier-locktime transactions is what makes them orphan the
|
||||
# previous ones.
|
||||
self._auto_sign_save_push()
|
||||
return True
|
||||
finally:
|
||||
# Always apply the cooldown so a burst of events (or the wallet
|
||||
# events fired by our own persistence) cannot loop forever.
|
||||
self._auto_rebuild_cooldown_until = (
|
||||
time.time() + self._AUTO_REBUILD_COOLDOWN
|
||||
)
|
||||
|
||||
def _auto_invalidate_will(self, will=None):
|
||||
"""Build, sign and broadcast the on-chain invalidation tx, headlessly.
|
||||
|
||||
Reuses the exact recipe of the wizard's ``loop_broadcast_invalidating``
|
||||
(label set before broadcast, tx info pulled from wallet/network,
|
||||
broadcast timeout 120s) without any dialog. An encrypted wallet cannot
|
||||
sign headlessly, so we stop with a logged warning and leave the
|
||||
invalidation to the user's manual flow.
|
||||
"""
|
||||
willitems = will if will is not None else self.willitems
|
||||
try:
|
||||
tx = Will.invalidate_will(
|
||||
willitems,
|
||||
self.wallet,
|
||||
self.will_settings.get("baltx_fees", 1),
|
||||
history_label=self.bal_plugin.HISTORY_LABEL.get(),
|
||||
will_locktime=Will.get_min_locktime(
|
||||
willitems,
|
||||
default_value=getattr(self, "date_to_check", None),
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("auto invalidate: could not build tx: {}".format(e))
|
||||
return None
|
||||
if not tx:
|
||||
_logger.info("auto invalidate: no transactions to invalidate")
|
||||
return None
|
||||
try:
|
||||
if self.wallet.has_keystore_encryption():
|
||||
_logger.warning(
|
||||
"auto invalidate: wallet is encrypted; signing the "
|
||||
"invalidation requires the password -> invalidate manually"
|
||||
)
|
||||
return None
|
||||
network = getattr(self.wallet, "network", None)
|
||||
if network is None:
|
||||
_logger.error("auto invalidate: no network, cannot broadcast")
|
||||
return None
|
||||
tx = self.wallet.sign_transaction(tx, None, ignore_warnings=True)
|
||||
if not tx or not tx.is_complete():
|
||||
raise Exception("invalidation tx not complete")
|
||||
tx.add_info_from_wallet(self.wallet)
|
||||
network.run_from_another_thread(tx.add_info_from_network(network))
|
||||
txid = tx.txid()
|
||||
if txid:
|
||||
# Label BEFORE broadcasting so the History tab shows it the
|
||||
# moment the tx appears (matches the wizard behaviour).
|
||||
self.wallet.set_label(txid, "BAL Invalidate transaction")
|
||||
network.run_from_another_thread(
|
||||
network.broadcast_transaction(tx, timeout=120), timeout=120
|
||||
)
|
||||
_logger.info("auto invalidate: broadcast invalidation {}".format(txid))
|
||||
return tx
|
||||
except Exception as e:
|
||||
_logger.error("auto invalidate failed: {}".format(e))
|
||||
return None
|
||||
|
||||
def _auto_sign_save_push(self):
|
||||
"""Headless sign + persist + push of the rebuilt will.
|
||||
|
||||
Mirrors the wizard's phase 2 (sign_transactions -> save_willitems ->
|
||||
push_transactions_to_willexecutors) without dialogs. Encrypted
|
||||
wallets cannot be signed headlessly, so the rebuilt transactions are
|
||||
left unsigned ("New") for the user to sign manually.
|
||||
"""
|
||||
try:
|
||||
if self.wallet.has_keystore_encryption():
|
||||
_logger.warning(
|
||||
"auto rebuild: wallet is encrypted; rebuilt will left "
|
||||
"unsigned (sign manually)"
|
||||
)
|
||||
else:
|
||||
txs = self.sign_transactions(None)
|
||||
if txs:
|
||||
for txid, tx in txs.items():
|
||||
# Store the signed tx back, like
|
||||
# ask_password_and_sign_transactions.on_success does
|
||||
# (re-parse instead of deepcopy: the signed tx may carry
|
||||
# wallet-derived input info holding a threading.RLock).
|
||||
self.willitems[txid].tx = Will.get_tx_from_any(str(tx))
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: signing failed: {}".format(e))
|
||||
try:
|
||||
self.save_willitems()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: save_willitems failed: {}".format(e))
|
||||
self._save_will_to_history()
|
||||
try:
|
||||
self.push_transactions_to_willexecutors()
|
||||
except Exception as e:
|
||||
_logger.error("auto rebuild: push failed: {}".format(e))
|
||||
|
||||
def on_close(self):
|
||||
# Wallet is closing: run the closing "build will" task and tear down
|
||||
# the plugin's tabs/menu. Each step is isolated so that one failure
|
||||
@@ -1044,9 +1430,12 @@ class BalWindow:
|
||||
return
|
||||
|
||||
# 1) Business logic: build/save the will on close (unchanged behaviour).
|
||||
# REBUILD_ON_CLOSE gates the "Build your will" wizard only: the will is
|
||||
# still persisted so a manual Build/Check from the session is not lost.
|
||||
try:
|
||||
close_window = BalBuildWillDialog(self)
|
||||
close_window.build_will_task()
|
||||
if self.bal_plugin.REBUILD_ON_CLOSE.get():
|
||||
close_window = BalBuildWillDialog(self)
|
||||
close_window.build_will_task()
|
||||
self.save_willitems()
|
||||
except Exception as e:
|
||||
_logger.error(f"on_close: build/save will failed: {e}")
|
||||
@@ -1252,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(
|
||||
@@ -1261,6 +1663,73 @@ class BalWindow:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def export_will_dialog(self, will=None, initial_mode: Optional[str] = None):
|
||||
"""Open the unified export window (File / QR / Audio).
|
||||
|
||||
The window lets the user pick an All / Valid / Valid NC filter in
|
||||
the top row and choose one of the three transports, each with its
|
||||
contextual settings (file format for File, QR-code size and autoplay
|
||||
for QR, KB/sec for Audio). ``will`` defaults to the live will items;
|
||||
``initial_mode`` opens the window directly on the given transport.
|
||||
"""
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
d = WillExportDialog(
|
||||
self,
|
||||
will=willitems,
|
||||
bal_plugin=self.bal_plugin,
|
||||
initial_mode=initial_mode or "file",
|
||||
)
|
||||
show_on_top(d)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
"""Return Electrum's ``audio_modem`` plugin instance, or None.
|
||||
|
||||
The plugin is only usable when Electrum exposes it (the ``Plugins``
|
||||
manager knows the name) and its optional runtime dependency
|
||||
``amodem`` is installed (:meth:`is_available`). Every other case
|
||||
returns None so callers can simply hide the audio buttons.
|
||||
"""
|
||||
try:
|
||||
p = self.window.gui_object.plugins.get("audio_modem")
|
||||
except Exception:
|
||||
return None
|
||||
if not p or not getattr(p, "is_available", lambda: False)():
|
||||
return None
|
||||
return p
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
"""Send a transfer payload through the audio_modem plugin.
|
||||
|
||||
Wraps the plugin's own ``_send`` with a proper parent widget. The
|
||||
audio channel zlib-compresses internally, so the payload is passed
|
||||
uncompressed (no BAL ``Z`` flag needed on that transport).
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
self.show_error(_("Audio MODEM plugin is not available."))
|
||||
return
|
||||
plugin._send(parent=self.window, blob=payload)
|
||||
|
||||
def set_audio_modem_bitrate(self, kbps):
|
||||
"""Set the ``audio_modem`` plugin transfer speed to ``kbps`` KB/sec.
|
||||
|
||||
Both the send and the receive paths read ``modem_config``, so the
|
||||
sender and the receiver must be configured with the same speed. Raises
|
||||
when the plugin (or its ``amodem`` dependency) is unavailable.
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
raise Exception(_("Audio MODEM plugin is not available."))
|
||||
try:
|
||||
import amodem.config
|
||||
except Exception as e:
|
||||
raise Exception(str(e)) from e
|
||||
plugin.modem_config = amodem.config.bitrates[int(kbps)]
|
||||
|
||||
def merge_will(self, imported):
|
||||
"""Merge imported will items into the live will.
|
||||
|
||||
@@ -1384,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:
|
||||
@@ -1403,6 +1890,18 @@ class BalWindow:
|
||||
|
||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
||||
|
||||
def import_will_dialog(self):
|
||||
"""Open the unified import window (File / QR / Audio).
|
||||
|
||||
The window offers three transports: File opens the read-only
|
||||
:class:`WillDetailDialog` preview; QR and Audio capture the
|
||||
transfer and send it through the per-transaction review wizard
|
||||
(:class:`WillTxReviewSignDialog`). Every flow works on fresh
|
||||
:class:`WillItem` objects and never touches the live will.
|
||||
"""
|
||||
d = WillImportDialog(self, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
|
||||
def _load_will_file(self, path):
|
||||
data = read_json_file(path)
|
||||
willitems = {}
|
||||
@@ -1411,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
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "bal",
|
||||
"fullname": "Bitcoin After Life",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||
"author": "Svatantrya",
|
||||
"licence": "MIT",
|
||||
"available_for": [
|
||||
"qt"
|
||||
"qt",
|
||||
"cmdline"
|
||||
],
|
||||
"icon": "icons/bal32x32.png"
|
||||
}
|
||||
@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — re‑Broadcast
|
||||
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
|
||||
</ol>
|
||||
|
||||
<footer>This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
||||
<footer>This document reflects BAL plugin v0.7.0. Behaviour is derived directly from
|
||||
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -357,5 +357,5 @@ that limit.
|
||||
|
||||
---
|
||||
|
||||
*This document reflects the current BAL plugin (v0.6.1). Behaviour is derived
|
||||
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
|
||||
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
||||
|
||||
@@ -475,6 +475,27 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
||||
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
||||
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
||||
> ensure the inheritance is correctly executed.
|
||||
>
|
||||
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
|
||||
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
|
||||
> flow when Electrum closes.
|
||||
|
||||
---
|
||||
|
||||
## Auto-rebuild on new transactions
|
||||
|
||||
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
|
||||
> (available in **Tools → Plugins → BAL**, default OFF).
|
||||
|
||||
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
|
||||
the will when new transactions are detected in the wallet (e.g. incoming
|
||||
payments). The delivery date is anticipated by one day so the new will orphans
|
||||
the old one on-chain without requiring a manual invalidation. An on-chain
|
||||
invalidation is only needed when the anticipated locktime crosses the **Check
|
||||
Alive** threshold (ADVANCED mode only).
|
||||
|
||||
This is useful for wallets that receive funds regularly: the inheritance stays
|
||||
up-to-date without manual intervention.
|
||||
|
||||
---
|
||||
|
||||
@@ -527,6 +548,32 @@ value.
|
||||
|
||||
---
|
||||
|
||||
## Command-line / headless usage
|
||||
|
||||
BAL can also be used without the Qt GUI, via Electrum's daemon mode. This is
|
||||
useful for scripting, automation, or running on a headless server.
|
||||
|
||||
**Prerequisites:** an Electrum daemon (`electrum daemon -d`) and a loaded wallet
|
||||
(`electrum load_wallet`).
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
electrum daemon -d
|
||||
electrum load_wallet
|
||||
electrum bal_heirs_list
|
||||
electrum bal_will_prepare
|
||||
electrum bal_will_sign --password '...'
|
||||
electrum bal_will_broadcast
|
||||
electrum stop
|
||||
```
|
||||
|
||||
All GUI operations (prepare, sign, broadcast, check, rebuild) are available as
|
||||
`bal_*` commands. See the full command table in the
|
||||
[README](../../README.md#command-line--headless-usage).
|
||||
|
||||
---
|
||||
|
||||
About installing a will‑executor server or collaboration, send your request to:
|
||||
**info@bitcoin-after.life**
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ 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/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
|
||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
|
||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
|
||||
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
||||
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
|
||||
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
|
||||
|
||||
11986
tests/karen7
11986
tests/karen7
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
|
||||
feature.
|
||||
|
||||
Covers:
|
||||
|
||||
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
|
||||
to OFF (False), and can be enabled and read back;
|
||||
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
|
||||
for the matching wallet and only when the setting is enabled;
|
||||
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
|
||||
re-entrancy / cooldown guards;
|
||||
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
|
||||
- no-op when the will is still valid;
|
||||
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
|
||||
invalidation, the rebuilt tx is anticipated to mine before the old);
|
||||
- on-chain invalidation when the check-alive threshold is already in the
|
||||
past (CheckAliveError);
|
||||
- on-chain invalidation when the will is already expired;
|
||||
- on-chain invalidation when the anticipated locktime would fall before
|
||||
the check-alive threshold (and no sign/push in that case).
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest.mock as mock
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from electrum import bitcoin, crypto # noqa: E402
|
||||
from electrum.descriptor import parse_descriptor # noqa: E402
|
||||
from electrum.transaction import ( # noqa: E402
|
||||
PartialTxInput,
|
||||
PartialTxOutput,
|
||||
TxOutpoint,
|
||||
)
|
||||
from electrum.util import bfh # noqa: E402
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bal.gui.qt.window as window_mod # noqa: E402
|
||||
from bal.core.heirs import Heirs # noqa: E402
|
||||
from bal.core.plugin_base import BalConfig, BalPlugin # noqa: E402
|
||||
from bal.core.util import Util # noqa: E402
|
||||
from bal.core.will import Will # noqa: E402
|
||||
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||
from bal.gui.qt.plugin import Plugin # noqa: E402
|
||||
from bal.gui.qt.window import BalWindow # noqa: E402
|
||||
|
||||
CONFIG_KEY = "bal_auto_rebuild"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
PRIVKEY = bytes(range(32))
|
||||
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||
FUNDING_SATOSHIS = 500000
|
||||
|
||||
|
||||
def make_funding_input(prevout_hex="11" * 32):
|
||||
"""Return a fake wallet UTXO spendable by the will."""
|
||||
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||
ADDRESS, FUNDING_SATOSHIS
|
||||
)
|
||||
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||
utxo._TxInput__scriptpubkey = SCRIPT
|
||||
utxo._TxInput__address = ADDRESS
|
||||
return utxo
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self, utxos):
|
||||
self.db = FakeDB()
|
||||
self.adb = None
|
||||
self.network = None
|
||||
self._utxos = list(utxos)
|
||||
self._dust = 546
|
||||
self._change_addresses = [ADDRESS]
|
||||
self.labels = {}
|
||||
self.save_db_calls = 0
|
||||
|
||||
def save_db(self):
|
||||
self.save_db_calls += 1
|
||||
|
||||
def dust_threshold(self):
|
||||
return self._dust
|
||||
|
||||
def has_keystore_encryption(self):
|
||||
return False
|
||||
|
||||
def set_label(self, txid, label):
|
||||
self.labels[txid] = label
|
||||
|
||||
def get_all_labels(self):
|
||||
return dict(self.labels)
|
||||
|
||||
def get_label_for_txid(self, txid):
|
||||
return self.labels.get(txid, "")
|
||||
|
||||
def get_utxos(self):
|
||||
return list(self._utxos)
|
||||
|
||||
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||
return self._change_addresses
|
||||
|
||||
def add_input_info(self, txin, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def add_output_info(self, txout, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def get_tx_info(self, tx):
|
||||
class _TxInfo:
|
||||
def __init__(self):
|
||||
class _MinedStatus:
|
||||
def height(self):
|
||||
return 0
|
||||
|
||||
self.tx_mined_status = _MinedStatus()
|
||||
|
||||
return _TxInfo()
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||
for txin in tx.inputs():
|
||||
if txin.script_descriptor is None:
|
||||
txin.script_descriptor = descriptor
|
||||
if txin.value_sats() is None:
|
||||
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||
tx.sign({PUBKEY: PRIVKEY})
|
||||
|
||||
|
||||
class FakeConfig:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
|
||||
|
||||
def electrum_path(self):
|
||||
return self._tmpdir
|
||||
|
||||
def user_dir(self):
|
||||
return self._tmpdir
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set_key(self, key, value, save=True):
|
||||
self._data[key] = value
|
||||
|
||||
|
||||
class FakeWindow:
|
||||
def __init__(self, wallet):
|
||||
self.wallet = wallet
|
||||
self.messages = []
|
||||
self.warnings = []
|
||||
self.errors = []
|
||||
|
||||
def get_decimal_point(self):
|
||||
return 0
|
||||
|
||||
def show_message(self, text):
|
||||
self.messages.append(str(text))
|
||||
|
||||
def show_warning(self, text, parent=None, title=None):
|
||||
self.warnings.append(str(text))
|
||||
|
||||
def show_error(self, text):
|
||||
self.errors.append(str(text))
|
||||
|
||||
def show_critical(self, text):
|
||||
self.errors.append(str(text))
|
||||
|
||||
def update_status(self):
|
||||
pass
|
||||
|
||||
|
||||
def make_controller(utxos=None):
|
||||
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
|
||||
utxos = [make_funding_input()] if utxos is None else utxos
|
||||
config = FakeConfig()
|
||||
wallet = FakeWallet(utxos)
|
||||
window = FakeWindow(wallet)
|
||||
|
||||
plugin = BalPlugin(None, config, "bal")
|
||||
plugin.get_window_title = lambda title: str(title)
|
||||
plugin.get_decimal_point = window.get_decimal_point
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
plugin.AUTO_REBUILD.set(True)
|
||||
|
||||
ctl = BalWindow.__new__(BalWindow)
|
||||
ctl.bal_plugin = plugin
|
||||
ctl.window = window
|
||||
ctl.wallet = wallet
|
||||
ctl.will = {}
|
||||
ctl.willitems = {}
|
||||
ctl.willexecutors = {}
|
||||
ctl.will_settings = plugin.WILL_SETTINGS.get()
|
||||
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||
ctl.heirs = Heirs(wallet)
|
||||
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
|
||||
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
|
||||
ctl.no_willexecutor = True
|
||||
ctl.disable_plugin = False
|
||||
ctl.ok = True
|
||||
ctl.update_all = lambda: None
|
||||
ctl._schedule_history_refresh = lambda: None
|
||||
ctl._auto_rebuild_running = False
|
||||
ctl._auto_rebuild_cooldown_until = 0.0
|
||||
return ctl
|
||||
|
||||
|
||||
def _no_willexecutors():
|
||||
"""Force an empty will-executor list (offline tests)."""
|
||||
return mock.patch.object(
|
||||
Willexecutors,
|
||||
"get_willexecutors",
|
||||
return_value={},
|
||||
)
|
||||
|
||||
|
||||
def _single(controller):
|
||||
"""Return (txid, WillItem) for the controller's single will item."""
|
||||
assert len(controller.willitems) == 1, controller.willitems
|
||||
return next(iter(controller.willitems.items()))
|
||||
|
||||
|
||||
def _item_spending(controller, *prevout_hexes):
|
||||
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||
wanted = sorted(h for h in prevout_hexes)
|
||||
items = [
|
||||
item
|
||||
for item in controller.willitems.values()
|
||||
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||
]
|
||||
assert len(items) == 1, controller.willitems
|
||||
return items[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config key
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_config_defaults_off():
|
||||
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||
assert rebuild.get() is False
|
||||
|
||||
|
||||
def test_auto_rebuild_config_can_be_enabled():
|
||||
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||
rebuild.set(True)
|
||||
assert rebuild.get() is True
|
||||
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Event wiring (Plugin._wallet_activity)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_wallet_activity_schedules_only_matching_wallet():
|
||||
plugin = Plugin.__new__(Plugin)
|
||||
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
|
||||
wallet_a = FakeWallet([make_funding_input()])
|
||||
wallet_b = FakeWallet([make_funding_input()])
|
||||
|
||||
scheduled = []
|
||||
|
||||
class _Win:
|
||||
wallet = wallet_a
|
||||
ok = True
|
||||
disable_plugin = False
|
||||
|
||||
def schedule_auto_rebuild(self):
|
||||
scheduled.append(self)
|
||||
|
||||
win = _Win()
|
||||
plugin.bal_windows = {"a": win}
|
||||
|
||||
plugin._wallet_activity(wallet_b)
|
||||
assert scheduled == [], "a different wallet must not schedule a rebuild"
|
||||
|
||||
plugin._wallet_activity(wallet_a)
|
||||
assert scheduled == [win], "the matching wallet must schedule a rebuild"
|
||||
|
||||
|
||||
def test_wallet_activity_skips_when_disabled():
|
||||
plugin = Plugin.__new__(Plugin)
|
||||
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
|
||||
wallet_obj = FakeWallet([make_funding_input()])
|
||||
|
||||
scheduled = []
|
||||
|
||||
class _Win:
|
||||
wallet = wallet_obj
|
||||
ok = True
|
||||
disable_plugin = False
|
||||
|
||||
def schedule_auto_rebuild(self):
|
||||
scheduled.append(self)
|
||||
|
||||
plugin.bal_windows = {"a": _Win()}
|
||||
|
||||
plugin._wallet_activity(wallet_obj)
|
||||
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scheduling / guards
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_schedule_auto_rebuild_debounces():
|
||||
ctl = make_controller()
|
||||
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
|
||||
ctl.schedule_auto_rebuild()
|
||||
single_shot.assert_called_once_with(
|
||||
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_guards():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
assert ctl._auto_rebuild_allowed() is True
|
||||
# Re-entrancy guard.
|
||||
ctl._auto_rebuild_running = True
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl._auto_rebuild_running = False
|
||||
# Cooldown guard.
|
||||
ctl._auto_rebuild_cooldown_until = time.time() + 100
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl._auto_rebuild_cooldown_until = 0.0
|
||||
assert ctl._auto_rebuild_allowed() is True
|
||||
# Disabled / inactive guards.
|
||||
ctl.disable_plugin = True
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
ctl.disable_plugin = False
|
||||
ctl.ok = False
|
||||
assert ctl._auto_rebuild_allowed() is False
|
||||
|
||||
|
||||
def test_run_auto_rebuild_spawns_worker_when_allowed():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
|
||||
started = []
|
||||
|
||||
class FakeThread:
|
||||
def __init__(self, target, daemon=None):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
started.append(self.target)
|
||||
|
||||
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
|
||||
ctl._run_auto_rebuild()
|
||||
assert len(started) == 1, "the worker thread must be spawned"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# maybe_auto_rebuild behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_noop_when_disabled():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
ctl.bal_plugin.AUTO_REBUILD.set(False)
|
||||
txid_before, _ = _single(ctl)
|
||||
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is False
|
||||
txid_after, _ = _single(ctl)
|
||||
assert txid_after == txid_before, "disabled flow must not touch the will"
|
||||
|
||||
|
||||
def test_auto_rebuild_noop_without_will():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
assert not ctl.willitems
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_auto_rebuild_noop_when_will_valid():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
txid_before, _ = _single(ctl)
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is False
|
||||
txid_after, _ = _single(ctl)
|
||||
assert txid_after == txid_before, "a valid will must not be rebuilt"
|
||||
inv.assert_not_called()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
# A relative delivery recipe keeps the will coherent after the rebuild
|
||||
# anticipates the locktime by one day (an absolute recipe would read the
|
||||
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
|
||||
ctl.will_settings["locktime"] = "1y"
|
||||
ctl.prepare_will()
|
||||
old_txid, old_item = _single(ctl)
|
||||
old_locktime = int(old_item.tx.locktime)
|
||||
|
||||
# An incoming payment adds a second UTXO -> the will no longer covers
|
||||
# the whole wallet (NotCompleteWillException).
|
||||
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "push_transactions_to_willexecutors"
|
||||
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True, "a stale will must be rebuilt"
|
||||
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
|
||||
push.assert_called_once()
|
||||
history.assert_called_once()
|
||||
|
||||
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
|
||||
# single-input transaction alongside it in the will).
|
||||
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
# The new locktime must be at most the old one, so the new tx can be mined
|
||||
# before the previous will.
|
||||
assert int(new_item.tx.locktime) <= old_locktime
|
||||
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||
assert new_item.get_status("VALID")
|
||||
|
||||
# The rebuilt will is still valid now: no further work.
|
||||
assert ctl.check_will() is True
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_threshold_passed():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
# ADVANCED mode with a check-alive threshold already in the past.
|
||||
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||
ctl.will_settings["threshold"] = int(time.time()) - 3600
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
ctl.prepare_will()
|
||||
txid, item = _single(ctl)
|
||||
# Move the frozen delivery date into the past: "too late to
|
||||
# anticipate" -> the old will must be invalidated on-chain.
|
||||
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||
|
||||
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||
ctl, "_auto_sign_save_push"
|
||||
) as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once()
|
||||
sign.assert_not_called()
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||
with _no_willexecutors():
|
||||
ctl = make_controller()
|
||||
now = time.time()
|
||||
delivery = int(now + 3 * 86400)
|
||||
ctl.will_settings["locktime"] = delivery
|
||||
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
|
||||
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||
ctl.will_settings["threshold"] = delivery - 12 * 3600
|
||||
|
||||
ctl.prepare_will()
|
||||
old_txid, _ = _single(ctl)
|
||||
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
# The rebuild itself anticipates the delivery date by one day ONLY when
|
||||
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
|
||||
# same coins + same heirs). Real amounts are re-computed against the
|
||||
# wallet balance, so a new UTXO normally changes them and the rebuilt
|
||||
# will keeps the old locktime. Force the anticipating branch here to
|
||||
# exercise the "anticipated locktime crosses the threshold" handling.
|
||||
with mock.patch.object(
|
||||
Will, "check_anticipate", return_value=delivery - 86400
|
||||
):
|
||||
with mock.patch.object(
|
||||
ctl, "_auto_invalidate_will"
|
||||
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
|
||||
result = ctl.maybe_auto_rebuild()
|
||||
|
||||
assert result is True
|
||||
inv.assert_called_once(), (
|
||||
"an anticipated locktime below the threshold must invalidate on-chain"
|
||||
)
|
||||
sign.assert_not_called(), (
|
||||
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||
"(the wizard stops and waits for the invalidation to confirm)"
|
||||
)
|
||||
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||
"the rebuilt locktime must be anticipated by one day"
|
||||
)
|
||||
|
||||
|
||||
def _run_all():
|
||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||
for fn in tests:
|
||||
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||
fn()
|
||||
print("OK")
|
||||
print(f"\n{len(tests)} tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_run_all()
|
||||
339
tests/test_cli_autorebuild.py
Normal file
339
tests/test_cli_autorebuild.py
Normal file
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the headless auto-rebuild flow (``bal_will_autorebuild``).
|
||||
|
||||
The CLI equivalent of the GUI AUTO_REBUILD feature:
|
||||
``BalController.auto_rebuild`` runs the wizard's close-time flow in a single
|
||||
call. Everything is exercised offline against a fake signing wallet (the same
|
||||
fixtures the GUI tests use), so no wallet, network or Qt is needed.
|
||||
|
||||
Covers:
|
||||
|
||||
* no-op when the will is still valid (``valid``);
|
||||
* rebuild + sign + push when a new UTXO invalidates the will (``rebuilt``,
|
||||
no on-chain invalidation: the rebuilt tx is anticipated to mine before
|
||||
the old one);
|
||||
* ``needs_signing`` when the wallet is encrypted;
|
||||
* on-chain invalidation when the will is already expired (``expired``);
|
||||
* on-chain invalidation when the anticipated locktime crosses the check-alive
|
||||
threshold (``anticipation_crossed``) - and no sign/push in that case.
|
||||
|
||||
The ``no_heirs`` and ``threshold_passed`` paths live in
|
||||
``test_cli_controller_offline.py``.
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 tests/test_cli_autorebuild.py
|
||||
"""
|
||||
|
||||
import os
|
||||
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))
|
||||
|
||||
from electrum import bitcoin, crypto
|
||||
from electrum.descriptor import parse_descriptor
|
||||
from electrum.simple_config import SimpleConfig
|
||||
from electrum.transaction import PartialTxInput, PartialTxOutput, TxOutpoint
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.cli.controller import BalController
|
||||
from bal.core.will import Will
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
PRIVKEY = bytes(range(32))
|
||||
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||
FUNDING_SATOSHIS = 500000
|
||||
|
||||
|
||||
def make_funding_input(prevout_hex="11" * 32):
|
||||
"""Return a fake wallet UTXO spendable by the will."""
|
||||
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||
ADDRESS, FUNDING_SATOSHIS
|
||||
)
|
||||
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||
utxo._TxInput__scriptpubkey = SCRIPT
|
||||
utxo._TxInput__address = ADDRESS
|
||||
return utxo
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
def get_dict(self, key):
|
||||
return self._data.setdefault(key, {})
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def add_transaction(self, tx, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self, utxos, encrypted=False):
|
||||
self.db = FakeDB()
|
||||
self.adb = None
|
||||
self.network = None
|
||||
self._utxos = list(utxos)
|
||||
self._dust = 546
|
||||
self._change_addresses = [ADDRESS]
|
||||
self._encrypted = encrypted
|
||||
self.labels = {}
|
||||
|
||||
def save_db(self):
|
||||
pass
|
||||
|
||||
def dust_threshold(self):
|
||||
return self._dust
|
||||
|
||||
def has_keystore_encryption(self):
|
||||
return self._encrypted
|
||||
|
||||
def set_label(self, txid, label):
|
||||
self.labels[txid] = label
|
||||
|
||||
def get_utxos(self):
|
||||
return list(self._utxos)
|
||||
|
||||
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||
return self._change_addresses
|
||||
|
||||
def add_input_info(self, txin, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def add_output_info(self, txout, only_der_suffix=False):
|
||||
pass
|
||||
|
||||
def get_tx_info(self, tx):
|
||||
class _TxInfo:
|
||||
def __init__(self):
|
||||
class _MinedStatus:
|
||||
def height(self):
|
||||
return 0
|
||||
|
||||
self.tx_mined_status = _MinedStatus()
|
||||
|
||||
return _TxInfo()
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||
for txin in tx.inputs():
|
||||
if txin.script_descriptor is None:
|
||||
txin.script_descriptor = descriptor
|
||||
if txin.value_sats() is None:
|
||||
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||
tx.sign({PUBKEY: PRIVKEY})
|
||||
|
||||
|
||||
class _Plugin:
|
||||
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||
|
||||
def __init__(self):
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_autorebuild_")
|
||||
from bal.cli.plugin import Plugin as RealPlugin
|
||||
|
||||
self.config = SimpleConfig(
|
||||
{"electrum_path": self.tmpdir},
|
||||
read_user_config_function=lambda path: {},
|
||||
)
|
||||
self.plugin = RealPlugin(None, self.config, "bal")
|
||||
|
||||
def __enter__(self):
|
||||
return self.plugin
|
||||
|
||||
def __exit__(self, *exc):
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _no_willexecutors():
|
||||
"""Force an empty will-executor list (offline tests)."""
|
||||
return mock.patch.object(
|
||||
Willexecutors,
|
||||
"get_willexecutors",
|
||||
return_value={},
|
||||
)
|
||||
|
||||
|
||||
def _make_controller(plugin, wallet):
|
||||
c = BalController(plugin, wallet)
|
||||
c.will_settings["locktime"] = "1y"
|
||||
c.heirs_add("alice", ADDRESS, "100000")
|
||||
c.heirs_add("bob", ADDRESS, "100%")
|
||||
return c
|
||||
|
||||
|
||||
def _single(controller):
|
||||
"""Return (txid, WillItem) for the controller's single will item."""
|
||||
assert len(controller.willitems) == 1, controller.willitems
|
||||
return next(iter(controller.willitems.items()))
|
||||
|
||||
|
||||
def _item_spending(controller, *prevout_hexes):
|
||||
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||
wanted = sorted(h for h in prevout_hexes)
|
||||
items = [
|
||||
item
|
||||
for item in controller.willitems.values()
|
||||
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||
]
|
||||
assert len(items) == 1, controller.willitems
|
||||
return items[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# auto_rebuild behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_auto_rebuild_noop_when_will_valid():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
txid_before, _ = _single(c)
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "valid", result
|
||||
assert next(iter(c.willitems)) == txid_before, (
|
||||
"a valid will must not be rebuilt"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
old_txid, old_item = _single(c)
|
||||
old_locktime = int(old_item.tx.locktime)
|
||||
|
||||
# An incoming payment adds a second UTXO -> the will no longer
|
||||
# covers the whole wallet (NotCompleteWillException).
|
||||
wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "rebuilt", result
|
||||
assert result["push"] == {}
|
||||
|
||||
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) <= old_locktime, (
|
||||
"the rebuilt tx must be anticipatable before the old will"
|
||||
)
|
||||
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||
assert new_item.get_status("VALID")
|
||||
|
||||
# The rebuilt will is still valid now: no further work.
|
||||
assert c.check_will() is True
|
||||
|
||||
|
||||
def test_auto_rebuild_encrypted_wallet_requires_manual_signing():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()], encrypted=True)
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "needs_signing", result
|
||||
assert result["will"]["count"] == 2
|
||||
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||
"an encrypted wallet must never be signed without the password"
|
||||
)
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.prepare_will()
|
||||
_, item = _single(c)
|
||||
# Move the frozen delivery date into the past: "too late to
|
||||
# anticipate" -> the old will must be invalidated on-chain.
|
||||
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "invalidated", result
|
||||
assert result["reason"] == "expired"
|
||||
assert result["invalidation_tx"]["txid"] is not None
|
||||
assert result["invalidation_tx"]["tx"]
|
||||
assert not any(w.get_status("COMPLETE") for w in c.willitems.values())
|
||||
|
||||
|
||||
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||
with _no_willexecutors():
|
||||
with _Plugin() as plugin:
|
||||
now = time.time()
|
||||
delivery = int(now + 3 * 86400)
|
||||
# ADVANCED mode: the check-alive threshold sits 12h before delivery,
|
||||
# so an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||
plugin.USER_TYPE.set("advanced")
|
||||
wallet = FakeWallet([make_funding_input()])
|
||||
plugin.NO_WILLEXECUTOR.set(True)
|
||||
c = _make_controller(plugin, wallet)
|
||||
c.will_settings["locktime"] = delivery
|
||||
c.will_settings["threshold"] = delivery - 12 * 3600
|
||||
c.prepare_will()
|
||||
old_txid, _ = _single(c)
|
||||
wallet._utxos.append(make_funding_input("22" * 32))
|
||||
|
||||
# Force the anticipating branch (see the GUI test for the rationale:
|
||||
# with a new UTXO the real amounts change, so the natural rebuild
|
||||
# keeps the old locktime).
|
||||
with mock.patch.object(
|
||||
Will, "check_anticipate", return_value=delivery - 86400
|
||||
):
|
||||
result = c.auto_rebuild()
|
||||
|
||||
assert result["result"] == "invalidated", result
|
||||
assert result["reason"] == "anticipation_crossed"
|
||||
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||
"(the wizard stops and waits for the invalidation to confirm)"
|
||||
)
|
||||
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||
"the rebuilt locktime must be anticipated by one day"
|
||||
)
|
||||
|
||||
|
||||
def _run_all():
|
||||
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||
for fn in tests:
|
||||
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||
fn()
|
||||
print("OK")
|
||||
print(f"\n{len(tests)} tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run_all()
|
||||
150
tests/test_cli_commands_registered.py
Normal file
150
tests/test_cli_commands_registered.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Test: BAL plugin CLI commands are registered with Electrum.
|
||||
|
||||
Verifies that importing the plugin through Electrum's own plugin loader
|
||||
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
|
||||
to pre-parse the command line) registers every ``bal_*`` command with
|
||||
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
|
||||
|
||||
It also asserts the basic contract enforced by ``plugin_command``: each command
|
||||
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
|
||||
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
|
||||
signing also ``'p'``).
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
python3 tests/test_cli_commands_registered.py
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import tempfile
|
||||
|
||||
from electrum import commands as electrum_commands
|
||||
from electrum.plugin import Plugins
|
||||
from electrum.simple_config import SimpleConfig
|
||||
|
||||
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
|
||||
# added in later phases must be appended here so the registration test keeps
|
||||
# proving the whole list is wired up.
|
||||
EXPECTED_COMMANDS = {
|
||||
# Settings (no wallet required)
|
||||
"bal_settings_list": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
"bal_settings_get": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
"bal_settings_set": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
"bal_settings_reset": {
|
||||
"requires_network": True,
|
||||
"requires_wallet": False,
|
||||
"requires_password": False,
|
||||
},
|
||||
# Heirs
|
||||
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
# Will-Executors
|
||||
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
# Will
|
||||
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_autorebuild": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
|
||||
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||
}
|
||||
|
||||
|
||||
def _isolated_config(**overrides):
|
||||
"""A throwaway SimpleConfig that never touches the real Electrum config.
|
||||
|
||||
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
|
||||
the tests cannot pollute the user's config files. The bal plugin is
|
||||
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
|
||||
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
|
||||
"""
|
||||
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
|
||||
opts.update(overrides)
|
||||
cfg = SimpleConfig(opts)
|
||||
cfg.enable_plugin("bal")
|
||||
return cfg
|
||||
|
||||
|
||||
def test_commands_registered():
|
||||
cfg = _isolated_config()
|
||||
Plugins(cfg, cmd_only=True)
|
||||
for name, flags in EXPECTED_COMMANDS.items():
|
||||
assert name in electrum_commands.known_commands, f"{name} not registered"
|
||||
cmd = electrum_commands.known_commands[name]
|
||||
assert cmd.name == name
|
||||
assert cmd.requires_network is flags["requires_network"]
|
||||
assert cmd.requires_wallet is flags["requires_wallet"]
|
||||
assert cmd.requires_password is flags["requires_password"]
|
||||
|
||||
|
||||
def test_commands_are_coroutines():
|
||||
cfg = _isolated_config()
|
||||
Plugins(cfg, cmd_only=True)
|
||||
for name in EXPECTED_COMMANDS:
|
||||
func = getattr(electrum_commands.Commands, name, None)
|
||||
assert func is not None, f"{name} missing from Commands"
|
||||
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
|
||||
|
||||
|
||||
def test_no_duplicate_registration():
|
||||
"""Loading the plugin twice must not raise "Command name bal_... already
|
||||
exists" (the guard in bal/__init__._register_cli_commands)."""
|
||||
cfg = _isolated_config()
|
||||
plugins = Plugins(cfg, cmd_only=True)
|
||||
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
|
||||
for name in EXPECTED_COMMANDS:
|
||||
assert name in electrum_commands.known_commands
|
||||
|
||||
|
||||
def test_command_docstrings_document_all_args():
|
||||
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
|
||||
CLI parser prints "undocumented argument ..." otherwise)."""
|
||||
cfg = _isolated_config()
|
||||
Plugins(cfg, cmd_only=True)
|
||||
for name in EXPECTED_COMMANDS:
|
||||
cmd = electrum_commands.known_commands[name]
|
||||
for varname in list(cmd.params) + list(cmd.options):
|
||||
if varname in ("wallet", "wallet_path", "plugin", "password"):
|
||||
continue
|
||||
assert varname in cmd.arg_descriptions, (
|
||||
f"{name}: undocumented argument {varname}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All CLI registration tests passed")
|
||||
252
tests/test_cli_controller_offline.py
Normal file
252
tests/test_cli_controller_offline.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Offline tests for the headless ``bal.cli.controller.BalController``.
|
||||
|
||||
These run without a wallet, a network or Qt: the controller is exercised
|
||||
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
|
||||
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
|
||||
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
|
||||
are covered here; build/sign/push flows need a live wallet and network and are
|
||||
exercised by the group tests instead.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_cli_controller_offline.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum.simple_config import SimpleConfig
|
||||
from electrum.util import UserFacingException
|
||||
|
||||
from bal.cli.controller import BalController
|
||||
|
||||
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self._data = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
def get_dict(self, key):
|
||||
return self._data.setdefault(key, {})
|
||||
|
||||
def get_transaction(self, txid):
|
||||
return None
|
||||
|
||||
def add_transaction(self, tx, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self):
|
||||
self.db = FakeDB()
|
||||
self.network = None
|
||||
self.adb = None
|
||||
self._dust = 500
|
||||
|
||||
def save_db(self):
|
||||
pass
|
||||
|
||||
def dust_threshold(self):
|
||||
return self._dust
|
||||
|
||||
def has_keystore_encryption(self):
|
||||
return False
|
||||
|
||||
def set_label(self, txid, text):
|
||||
pass
|
||||
|
||||
def get_utxos(self):
|
||||
return []
|
||||
|
||||
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||
return [VALID_ADDRESS]
|
||||
|
||||
|
||||
class Plugin:
|
||||
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||
|
||||
def __init__(self):
|
||||
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
|
||||
from bal.cli.plugin import Plugin as RealPlugin
|
||||
|
||||
self.config = SimpleConfig(
|
||||
{"electrum_path": self.tmpdir},
|
||||
read_user_config_function=lambda path: {},
|
||||
)
|
||||
self.plugin = RealPlugin(None, self.config, "bal")
|
||||
|
||||
def __enter__(self):
|
||||
return self.plugin
|
||||
|
||||
def __exit__(self, *exc):
|
||||
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _make_controller(plugin):
|
||||
return BalController(plugin, FakeWallet())
|
||||
|
||||
|
||||
def test_controller_init_empty():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
assert c.willitems == {}
|
||||
assert c.will == {}
|
||||
assert c.heirs == {}
|
||||
assert isinstance(c.will_settings, dict)
|
||||
assert "baltx_fees" in c.will_settings
|
||||
# Fresh config: no stored will-executors. On mainnet the default
|
||||
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
|
||||
# "bitcoin", so nothing is injected either.
|
||||
assert c.willexecutors == {}
|
||||
assert c.no_willexecutor is False
|
||||
|
||||
|
||||
def test_settings_roundtrip():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
listing = c.settings_list()
|
||||
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
|
||||
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
|
||||
assert c.settings_get(tx_key)["value"] == 100
|
||||
|
||||
c.settings_set("bal_tx_fees", "150")
|
||||
assert c.settings_get("bal_tx_fees")["value"] == 150
|
||||
assert c.settings_get("TX_FEES")["value"] == 150
|
||||
|
||||
c.settings_set("bal_no_willexecutor", "true")
|
||||
assert c.settings_get("bal_no_willexecutor")["value"] is True
|
||||
|
||||
c.settings_reset("bal_tx_fees")
|
||||
assert c.settings_get("bal_tx_fees")["value"] == 100
|
||||
|
||||
|
||||
def test_settings_unknown_key():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
try:
|
||||
c.settings_get("bal_does_not_exist")
|
||||
raise AssertionError("expected UserFacingException")
|
||||
except UserFacingException as e:
|
||||
assert "Unknown BAL setting" in str(e)
|
||||
|
||||
|
||||
def test_heirs_crud():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||
assert c.heirs["alice"][0] == VALID_ADDRESS
|
||||
assert c.heirs["alice"][1] == "100000"
|
||||
|
||||
c.heirs_update("alice", amount="200000")
|
||||
assert c.heirs["alice"][1] == "200000"
|
||||
assert c.heirs_show("alice")["value"][1] == "200000"
|
||||
|
||||
assert "alice" in c.heirs_list()
|
||||
c.heirs_delete(["alice"])
|
||||
assert "alice" not in c.heirs_list()
|
||||
|
||||
|
||||
def test_heirs_add_op_return():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
|
||||
assert c.heirs["note"][1] == "0"
|
||||
|
||||
|
||||
def test_willexecutors_crud():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
assert c.willexecutors == {}
|
||||
|
||||
new_url = "https://executor.example.invalid"
|
||||
c.willexecutors_add(new_url, address="", base_fee=250)
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
|
||||
|
||||
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
|
||||
|
||||
c.willexecutors_select([new_url], select=True)
|
||||
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
|
||||
|
||||
renamed = "https://executor2.example.invalid"
|
||||
c.willexecutors_update(new_url, rename_to=renamed)
|
||||
assert renamed in c.willexecutors
|
||||
assert new_url not in c.willexecutors
|
||||
|
||||
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
|
||||
assert renamed not in c.willexecutors
|
||||
|
||||
|
||||
def test_will_status_empty():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
status = c.will_status()
|
||||
assert status["count"] == 0
|
||||
assert status["items"] == []
|
||||
|
||||
|
||||
def test_will_check_no_heirs_raises():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
try:
|
||||
c.will_check()
|
||||
raise AssertionError("expected UserFacingException")
|
||||
except UserFacingException as e:
|
||||
assert "heir" in str(e).lower()
|
||||
|
||||
|
||||
def test_auto_rebuild_no_heirs():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
assert c.auto_rebuild() == {"result": "no_heirs"}
|
||||
|
||||
|
||||
def test_auto_rebuild_threshold_passed_invalidates():
|
||||
with Plugin() as plugin:
|
||||
c = _make_controller(plugin)
|
||||
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||
plugin.USER_TYPE.set("advanced")
|
||||
c.will_settings["threshold"] = int(time.time()) - 3600
|
||||
result = c.auto_rebuild()
|
||||
assert result["result"] == "invalidated"
|
||||
assert result["reason"] == "threshold_passed"
|
||||
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# runner
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def main():
|
||||
failures = 0
|
||||
for name, fn in sorted(globals().items()):
|
||||
if not name.startswith("test_") or not callable(fn):
|
||||
continue
|
||||
print(f" {name}")
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
failures += 1
|
||||
print(f" [FAIL] {name}: {e!r}")
|
||||
if failures:
|
||||
print(f"[FAIL] {failures} test(s) failed")
|
||||
sys.exit(1)
|
||||
print("[OK] All offline controller tests passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
478
tests/test_core_animated_qr.py
Normal file
478
tests/test_core_animated_qr.py
Normal file
@@ -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")
|
||||
@@ -61,14 +61,14 @@ def test_advanced_mode_uses_threshold_absolute():
|
||||
def test_advanced_mode_parses_relative_threshold():
|
||||
# A relative threshold means "N days BEFORE the delivery": it resolves
|
||||
# against the stored locktime (backwards), not forward from now.
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
fake_now = 1_800_000_000.0
|
||||
locktime = fake_now + 90 * 86400
|
||||
settings = {"threshold": "30d", "locktime": locktime}
|
||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
||||
expected = (datetime.fromtimestamp(locktime)
|
||||
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
- timedelta(days=30)).timestamp()
|
||||
assert abs(result - expected) < 1
|
||||
@@ -91,7 +91,7 @@ def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
||||
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||
"""A relative locktime is resolved against 'now' first, then the relative
|
||||
threshold counts N days back from it (matches the settings widget)."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bal.core.plugin_base import BalTimestamp
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||
# Recompute the expected value with the same resolution rules:
|
||||
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
|
||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||
assert abs(result - expected) < 1
|
||||
assert result > fake_now
|
||||
@@ -117,7 +117,7 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
||||
frozen = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
|
||||
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
|
||||
settings = {"threshold": "30d", "locktime": "2y"}
|
||||
# On build day the frozen delivery is authoritative: date_to_check is
|
||||
# frozen - 30d and NEVER drifts, however much later the clock gets.
|
||||
@@ -136,14 +136,14 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
||||
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
||||
"""Without a built will there is no anchor: keeps the legacy now-based
|
||||
resolution (a moving target, used only before the first build)."""
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bal.core.plugin_base import BalTimestamp
|
||||
|
||||
fake_now = 1_800_000_000.0
|
||||
settings = {"threshold": "30d", "locktime": "90d"}
|
||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
|
||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
|
||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||
assert abs(result - expected) < 1
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
326
tests/test_core_qr_transfer.py
Normal file
326
tests/test_core_qr_transfer.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
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")
|
||||
@@ -97,7 +97,7 @@ def test_relative_days():
|
||||
|
||||
def test_resolve_locktime_against_tx_absolute():
|
||||
"""An absolute current date is returned unchanged (compared vs the tx)."""
|
||||
frozen = 1817438400
|
||||
frozen = 1817424000
|
||||
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
|
||||
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
|
||||
|
||||
@@ -105,7 +105,7 @@ def test_resolve_locktime_against_tx_absolute():
|
||||
def test_resolve_locktime_against_tx_unchanged_relative():
|
||||
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
|
||||
(coherent), instead of drifting one day per day away from it."""
|
||||
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
|
||||
frozen = 1817424000 # 2027-08-05 00:00 UTC, i.e. a tx built 2026-08-05 with "1y"
|
||||
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
|
||||
assert resolved == frozen
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_resolve_locktime_against_tx_unchanged_relative():
|
||||
def test_resolve_locktime_against_tx_lengthened():
|
||||
"""A lengthened relative recipe resolves later than the frozen tx locktime
|
||||
(this is what the postpone check uses to trigger invalidation)."""
|
||||
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
|
||||
frozen = 1817424000 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
|
||||
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
|
||||
assert resolved == frozen + 365 * 86400
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_resolve_locktime_against_tx_lengthened():
|
||||
def test_resolve_locktime_against_tx_shortened():
|
||||
"""A shortened relative recipe resolves earlier than the frozen tx locktime
|
||||
(this is what the anticipate/rebuild path uses)."""
|
||||
frozen = 1817438400
|
||||
frozen = 1817424000
|
||||
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
|
||||
assert resolved < frozen
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_resolve_locktime_against_tx_shortened():
|
||||
def test_resolve_locktime_against_tx_no_relative_anchor():
|
||||
"""When the built recipe was absolute there is no anchor: falls back to the
|
||||
legacy forward-from-now resolution (returns a timestamp, no crash)."""
|
||||
frozen = 1817438400
|
||||
frozen = 1817424000
|
||||
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
|
||||
assert isinstance(result, int)
|
||||
assert result > 1700000000
|
||||
@@ -319,27 +319,6 @@ def test_anticipate_locktime():
|
||||
assert low >= 1
|
||||
|
||||
|
||||
def test_cmp_locktime():
|
||||
assert Util.cmp_locktime("30d", "30d") == 0
|
||||
# Note: cmp_locktime may return nonzero or None for mismatched units
|
||||
|
||||
|
||||
def test_get_locktimes():
|
||||
class FakeTx:
|
||||
locktime = 1700000000
|
||||
|
||||
# will with single entry
|
||||
will = {
|
||||
"tx1": {"tx": FakeTx()},
|
||||
}
|
||||
locktimes = list(Util.get_locktimes(will))
|
||||
assert 1700000000 in locktimes
|
||||
assert len(locktimes) == 1
|
||||
|
||||
# empty will
|
||||
assert list(Util.get_locktimes({})) == []
|
||||
|
||||
|
||||
def test_get_lowest_locktimes():
|
||||
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
||||
# 500000, 900000 are block-height (< THRESHOLD)
|
||||
@@ -351,18 +330,6 @@ def test_get_lowest_locktimes():
|
||||
assert Util.get_lowest_locktimes([]) == ([], [])
|
||||
|
||||
|
||||
def test_get_will_spent_utxos():
|
||||
class FakeTx:
|
||||
def inputs(self): return [1, 2, 3]
|
||||
|
||||
will = {
|
||||
"tx1": {"tx": FakeTx()},
|
||||
"tx2": {"tx": FakeTx()},
|
||||
}
|
||||
utxos = Util.get_will_spent_utxos(will)
|
||||
assert len(utxos) == 6 # 3 inputs * 2 txs
|
||||
|
||||
|
||||
def test_utxo_to_str():
|
||||
class FakeUtxo:
|
||||
def to_str(self): return "txid:0"
|
||||
@@ -518,10 +485,7 @@ if __name__ == "__main__":
|
||||
test_get_value_amount()
|
||||
test_chk_locktime()
|
||||
test_anticipate_locktime()
|
||||
test_cmp_locktime()
|
||||
test_get_locktimes()
|
||||
test_get_lowest_locktimes()
|
||||
test_get_will_spent_utxos()
|
||||
test_utxo_to_str()
|
||||
test_cmp_utxo()
|
||||
test_in_utxo()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -126,22 +126,6 @@ def test_willitem_str_repr():
|
||||
# Will static methods
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_will_get_sorted_will():
|
||||
# Use a simple dict structure that will[key]["tx"].locktime works
|
||||
class FakeTx:
|
||||
def __init__(self, locktime):
|
||||
self.locktime = locktime
|
||||
|
||||
will = {
|
||||
"b": {"tx": FakeTx(200)},
|
||||
"a": {"tx": FakeTx(100)},
|
||||
}
|
||||
sorted_will = Will.get_sorted_will(will)
|
||||
assert len(sorted_will) == 2
|
||||
assert sorted_will[0][1]["tx"].locktime == 100
|
||||
assert sorted_will[1][1]["tx"].locktime == 200
|
||||
|
||||
|
||||
def test_will_only_valid():
|
||||
item1 = _make_willitem_blank()
|
||||
item2 = _make_willitem_blank()
|
||||
@@ -167,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}
|
||||
|
||||
@@ -179,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
|
||||
|
||||
|
||||
@@ -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,9 +74,12 @@ 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.
|
||||
item.tx.inputs()[0]._trusted_value_sats = value_sats
|
||||
# Use the name-mangled attribute because tx_from_any creates a
|
||||
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
|
||||
# reads __value_sats, not _trusted_value_sats.
|
||||
item.tx.inputs()[0]._TxInput__value_sats = value_sats
|
||||
if not valid:
|
||||
item.set_status("INVALIDATED", True)
|
||||
return item
|
||||
@@ -267,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)
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
@@ -281,10 +281,10 @@ class TestKaren7BuildAndInvalidate:
|
||||
)
|
||||
|
||||
def test_built_tx_has_karen7_heirs(self):
|
||||
"""The built will contains karen7's four heirs."""
|
||||
assert len(self.heirs_model) == 4
|
||||
"""The built will contains karen7's six heirs."""
|
||||
assert len(self.heirs_model) == 6
|
||||
assert list(self.heirs_model.keys()) == [
|
||||
"aaaa", "lucia", "mario", "mario2"
|
||||
"aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"
|
||||
]
|
||||
|
||||
def test_will_items_are_valid(self):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -570,8 +570,8 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
|
||||
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
|
||||
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
|
||||
assert len(h) == 6, f"expected 6 heirs, got {len(h)}"
|
||||
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"]
|
||||
|
||||
# Mock the Electrum-heavy parts so the build can run in a test context.
|
||||
wallet = MagicMock()
|
||||
|
||||
456
tests/test_gui_export_dialogs.py
Normal file
456
tests/test_gui_export_dialogs.py
Normal file
@@ -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")
|
||||
749
tests/test_gui_qr_transfer.py
Normal file
749
tests/test_gui_qr_transfer.py
Normal file
@@ -0,0 +1,749 @@
|
||||
"""
|
||||
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||
|
||||
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 MagicMock, 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 CHUNK_PRESETS, 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 _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
|
||||
|
||||
|
||||
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 to_dict(self):
|
||||
return {"tx": str(self.tx), "status": self.statuses}
|
||||
|
||||
|
||||
def _make_willitems(n=3, payload_len=120):
|
||||
return {
|
||||
"item{}".format(i): StubWillItem("T{}".format(i) * payload_len)
|
||||
for i in range(n)
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_dialog_builds():
|
||||
bw = FakeBalWindow()
|
||||
bw.willitems = _make_willitems()
|
||||
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 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 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.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
|
||||
|
||||
|
||||
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.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()
|
||||
|
||||
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.
|
||||
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.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.
|
||||
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.
|
||||
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()
|
||||
|
||||
|
||||
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.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(page.tx_strings) == ["A" * 120, "B" * 120]
|
||||
|
||||
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||
d._on_filter_change(2)
|
||||
assert sorted(page.tx_strings) == ["B" * 120]
|
||||
assert page.qr_view.text == page.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.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||
messages = []
|
||||
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.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.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 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.
|
||||
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||
assert len(page.frames) < first_count
|
||||
assert page.index == 0
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog (QR transport via d.qr_page)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_import_frame_flow():
|
||||
bw = FakeBalWindow()
|
||||
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:
|
||||
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 page.slot_area.isHidden()
|
||||
assert len(page.slot_widgets) == page.total
|
||||
assert "All" in page.status_label.text()
|
||||
# Duplicate capture is harmless.
|
||||
page._add_frame(frames[0])
|
||||
assert len(page.frames) == page.total
|
||||
d.close()
|
||||
|
||||
|
||||
def test_import_assembles_and_decodes():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
captured = {}
|
||||
|
||||
def fake_finish(payload):
|
||||
captured["payload"] = payload
|
||||
|
||||
page._finish_import = fake_finish
|
||||
transfer = encode_transfer(["P" * 130, "Q" * 130])
|
||||
for frame in split_frames(transfer, 150):
|
||||
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.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)
|
||||
|
||||
# A frame with a different total wipes the import; the first frame of
|
||||
# the new transfer must be scanned afresh.
|
||||
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_manual_entry():
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
page = d.qr_page
|
||||
frames = split_frames(encode_transfer(["M" * 120]), 150)
|
||||
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()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 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")
|
||||
@@ -269,7 +269,9 @@ def test_prepare_will_builds_and_persists():
|
||||
|
||||
assert item.get_status("VALID"), "fresh items default to VALID"
|
||||
assert txid == item.tx.txid()
|
||||
assert isinstance(txid, str) and txid.startswith("2"), "raw tx id expected"
|
||||
assert isinstance(txid, str) and len(txid) == 64 and all(
|
||||
c in "0123456789abcdef" for c in txid
|
||||
), "raw tx id expected (64-char hex, not a label/short id)"
|
||||
assert not item.tx.is_complete(), "unsigned will must not be complete"
|
||||
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -61,9 +61,9 @@ _VALID_TX_HEX = (
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
|
||||
# will built 2026-08-05 with a "1y" recipe.
|
||||
_FROZEN = 1817438400
|
||||
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05 00:00 UTC,
|
||||
# i.e. a will built 2026-08-05 with a "1y" recipe.
|
||||
_FROZEN = 1817424000
|
||||
|
||||
|
||||
def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||
@@ -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
|
||||
|
||||
@@ -173,15 +173,16 @@ def test_karen7_frozen_delivery_not_expired():
|
||||
window opens BEFORE the delivery, so the will is never read as expired."""
|
||||
data = _load_karen7()
|
||||
will_settings = data["will_settings"]
|
||||
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
||||
assert built_locktime == _FROZEN
|
||||
assert built_locktime is not None
|
||||
assert built_locktime == int(wi.tx.locktime)
|
||||
|
||||
date_to_check = resolve_date_to_check(
|
||||
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
||||
)
|
||||
assert int(date_to_check) < _FROZEN
|
||||
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,
|
||||
@@ -191,24 +192,26 @@ def test_karen7_frozen_delivery_not_expired():
|
||||
|
||||
|
||||
def test_karen7_unchanged_heirs_are_coherent():
|
||||
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
|
||||
"""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 = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
||||
# 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"],
|
||||
now=1_800_000_000.0,
|
||||
built_locktime=int(wi.tx.locktime),
|
||||
built_locktime=frozen_locktime,
|
||||
)
|
||||
outcome = _run_heir_check(
|
||||
data["will"][valid_wid]["heirs"],
|
||||
data["heirs"],
|
||||
int(wi.tx.locktime),
|
||||
frozen_locktime,
|
||||
status_complete=True,
|
||||
)
|
||||
assert outcome.startswith("coherent"), outcome
|
||||
assert int(date_to_check) < int(wi.tx.locktime)
|
||||
assert int(date_to_check) < frozen_locktime
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -392,7 +392,7 @@ class TestNoWillexecutorKaren7:
|
||||
heirs_data = _KAREN7_DATA["heirs"]
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
assert len(h) == 4
|
||||
assert len(h) == 6
|
||||
|
||||
self.heirs_obj = h
|
||||
self.bal_plugin = _Karen7BalPlugin()
|
||||
|
||||
165
tests/test_rebuild_on_close_setting.py
Normal file
165
tests/test_rebuild_on_close_setting.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the "Rebuild will on wallet close" (REBUILD_ON_CLOSE) setting.
|
||||
|
||||
Covers:
|
||||
|
||||
* the persisted ``bal_rebuild_on_close`` configuration key exists and
|
||||
defaults to ON (True), and can be turned off and read back;
|
||||
* ``BalWindow.on_close()`` runs the "Build your will" wizard
|
||||
(``BalBuildWillDialog.build_will_task()``) when the setting is ON;
|
||||
* ``BalWindow.on_close()`` SKIPS the wizard when the setting is OFF, but
|
||||
still calls ``save_willitems()`` so the last built state is persisted.
|
||||
|
||||
The on_close tests drive the real ``BalWindow.on_close`` method with a
|
||||
light-weight fake controller and a recording stub for ``BalBuildWillDialog``,
|
||||
so no full wallet/GUI machinery is needed.
|
||||
|
||||
Run:
|
||||
source "$BAL_HOME/electrum/env/bin/activate"
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_rebuild_on_close_setting.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import bal.gui.qt.window as window_mod # noqa: E402
|
||||
from bal.core.plugin_base import BalConfig # noqa: E402
|
||||
|
||||
CONFIG_KEY = "bal_rebuild_on_close"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mocks
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class FakeConfig:
|
||||
"""Minimal mock for Electrum's config object (key/value store)."""
|
||||
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._store.get(key, default)
|
||||
|
||||
def set_key(self, key, value, save=True):
|
||||
self._store[key] = value
|
||||
|
||||
|
||||
class FakeBuildWillDialog:
|
||||
"""Recording stub for BalBuildWillDialog, patched into window.py."""
|
||||
|
||||
instances = []
|
||||
|
||||
def __init__(self, bal_window):
|
||||
self.bal_window = bal_window
|
||||
FakeBuildWillDialog.instances.append(self)
|
||||
|
||||
def build_will_task(self):
|
||||
self.bal_window._wizard_ran = True
|
||||
|
||||
|
||||
class _Tabs:
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
|
||||
class _NoOp:
|
||||
willexecutors_action = None
|
||||
tabs = _Tabs()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def toggle_tab(self, tab):
|
||||
pass
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def removeAction(self, action):
|
||||
pass
|
||||
|
||||
|
||||
def _make_fake_window(rebuild_on_close):
|
||||
"""Return a fake controller with the attributes on_close() touches."""
|
||||
fake = types.SimpleNamespace()
|
||||
fake.disable_plugin = False
|
||||
fake.bal_plugin = types.SimpleNamespace(
|
||||
REBUILD_ON_CLOSE=BalConfig(FakeConfig(), CONFIG_KEY, rebuild_on_close)
|
||||
)
|
||||
fake.willitems = {}
|
||||
fake.will = {}
|
||||
fake.saved = []
|
||||
fake.save_willitems = lambda: fake.saved.append("save")
|
||||
fake.heirs_tab = _NoOp()
|
||||
fake.will_tab = _NoOp()
|
||||
fake.tools_menu = _NoOp()
|
||||
fake.window = _NoOp()
|
||||
fake._menubar_initialized = True
|
||||
return fake
|
||||
|
||||
|
||||
def _call_on_close(fake):
|
||||
original = window_mod.BalBuildWillDialog
|
||||
FakeBuildWillDialog.instances = []
|
||||
try:
|
||||
window_mod.BalBuildWillDialog = FakeBuildWillDialog
|
||||
window_mod.BalWindow.on_close(fake)
|
||||
finally:
|
||||
window_mod.BalBuildWillDialog = original
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config key
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_rebuild_on_close_config_defaults_on():
|
||||
"""bal_rebuild_on_close must default to ON (True) when not yet stored."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, True)
|
||||
assert rebuild.get() is True
|
||||
|
||||
|
||||
def test_rebuild_on_close_config_can_be_disabled():
|
||||
"""Once turned off and persisted, bal_rebuild_on_close reads back False."""
|
||||
cfg = FakeConfig()
|
||||
rebuild = BalConfig(cfg, CONFIG_KEY, True)
|
||||
rebuild.set(False)
|
||||
assert rebuild.get() is False
|
||||
# A fresh wrapper over the same config still sees the stored value.
|
||||
assert BalConfig(cfg, CONFIG_KEY, True).get() is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# on_close() behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_on_close_runs_wizard_when_enabled():
|
||||
"""With REBUILD_ON_CLOSE ON, on_close() builds the will and saves it."""
|
||||
fake = _make_fake_window(True)
|
||||
_call_on_close(fake)
|
||||
assert len(FakeBuildWillDialog.instances) == 1, "wizard must be opened"
|
||||
assert fake._wizard_ran is True, "wizard build_will_task must run"
|
||||
assert fake.saved == ["save"], "save_willitems must run"
|
||||
|
||||
|
||||
def test_on_close_skips_wizard_when_disabled():
|
||||
"""With REBUILD_ON_CLOSE OFF, on_close() skips the wizard but saves."""
|
||||
fake = _make_fake_window(False)
|
||||
_call_on_close(fake)
|
||||
assert len(FakeBuildWillDialog.instances) == 0, "wizard must NOT be opened"
|
||||
assert not hasattr(fake, "_wizard_ran"), "wizard must not run"
|
||||
assert fake.saved == ["save"], "save_willitems must still run"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_rebuild_on_close_config_defaults_on()
|
||||
test_rebuild_on_close_config_can_be_disabled()
|
||||
test_on_close_runs_wizard_when_enabled()
|
||||
test_on_close_skips_wizard_when_disabled()
|
||||
print("OK: all tests passed")
|
||||
@@ -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)
|
||||
@@ -170,7 +170,7 @@ def test_simulate_task_phase1():
|
||||
heirs_data = KAREN7_DATA["heirs"]
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
assert len(h) == 4
|
||||
assert len(h) == 6
|
||||
|
||||
# 2. Build UTXOs
|
||||
utxos = build_utxos(KAREN7_DATA)
|
||||
|
||||
Reference in New Issue
Block a user