Compare commits
9 Commits
feature/cm
...
9c4697c923
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c4697c923 | |||
| 42f05d3c4f | |||
| 3a9ee5adb9 | |||
| 2c9f6bdd9d | |||
| 4dfb3fc41c | |||
| 1ae1617172 | |||
| 125bf09b9a | |||
| b5752a42f0 | |||
| ce659048ca |
28
AGENTS.md
28
AGENTS.md
@@ -20,8 +20,9 @@ The plugin's `bal/` directory is symlinked into
|
|||||||
|
|
||||||
## Test & verify
|
## Test & verify
|
||||||
|
|
||||||
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
|
Tests work **both** as standalone scripts and via pytest (tests use `def test_*`
|
||||||
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
naming and also have `if __name__ == "__main__"` blocks). Run a single file
|
||||||
|
directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source "$BAL_HOME/electrum/env/bin/activate"
|
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
|
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
|
- Most core tests run offline (no wallet/network). Some files
|
||||||
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
|
(`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
|
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
|
- **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;
|
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
|
||||||
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
|
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`
|
- Lint via the repo venv: `./venv/bin/ruff`
|
||||||
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
|
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
|
||||||
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
|
(`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
|
## Architecture
|
||||||
|
|
||||||
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
|
- `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,
|
- `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
|
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
|
||||||
`make-release.sh`).
|
`make-release.sh`).
|
||||||
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
|
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
|
||||||
|
|||||||
425
CHANGELOG.md
425
CHANGELOG.md
@@ -2568,3 +2568,428 @@ of a session, without requiring the normal wizard flow to have run first.
|
|||||||
`test_merge_will_validity_error_logs_without_crashing`.
|
`test_merge_will_validity_error_logs_without_crashing`.
|
||||||
|
|
||||||
**Outcome:** DONE.
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**Outcome:** DONE.
|
||||||
|
|||||||
19
HANDOFF.md
19
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)
|
__init__.py <- package docstring (no version here anymore)
|
||||||
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
|
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
|
qt.py <- zipimport shim used when loaded as an external ZIP plugin
|
||||||
|
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
|
||||||
core/
|
core/
|
||||||
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
||||||
heirs.py <- HEIRS + transaction building (prepare_lists,
|
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,
|
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
||||||
parallel push/check).
|
parallel push/check).
|
||||||
util.py <- locktime parsing/most helpers (timestamps only).
|
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/
|
gui/qt/
|
||||||
common.py <- shared imports; every gui module does
|
common.py <- shared imports; every gui module does
|
||||||
`from .common import *`. Add new shared imports HERE.
|
`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
|
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
||||||
tests/ <- standalone test scripts (see Section 3).
|
tests/ <- standalone test scripts (see Section 3).
|
||||||
docs/ <- user manual + inheritance-options guide (.md sources).
|
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).
|
build_zip.py <- builds the shippable ZIP (36 files).
|
||||||
CHANGELOG.md <- numbered task log (English).
|
CHANGELOG.md <- numbered task log (English).
|
||||||
.agent_memory_tasks.md <- terse internal memory notes per task batch.
|
.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.
|
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
|
||||||
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
||||||
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
||||||
manifest.json version). All merged into `main`.
|
manifest.json version); all merged into `main`.
|
||||||
- Releases: latest is **v0.6.1**; v0.6.0 and v0.5.18 before it; the older
|
- 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.
|
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
|
- **#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
|
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
|
||||||
CHANGELOG).
|
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)
|
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
|
||||||
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
|
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
|
||||||
|
|||||||
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.
|
||||||
56
README.md
56
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
|
that transfer your funds to your heirs if you stop refreshing them
|
||||||
(dead-man's switch), optionally relayed by will-executor servers.
|
(dead-man's switch), optionally relayed by will-executor servers.
|
||||||
|
|
||||||
This repository contains a **behavior-preserving refactor** of the original
|
This repository contains a **refactored and extended** version of the original
|
||||||
plugin. The logic was kept byte-identical wherever possible; only the file
|
plugin. The logic was reorganized to cleanly separate **business logic** from the
|
||||||
layout was reorganized to cleanly separate **business logic** from the
|
**PyQt GUI**, and new features have been added including a headless CLI,
|
||||||
**PyQt GUI**.
|
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
|
||||||
|
reminders.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -16,12 +17,20 @@ layout was reorganized to cleanly separate **business logic** from the
|
|||||||
bal/ the installable Electrum plugin package
|
bal/ the installable Electrum plugin package
|
||||||
├── manifest.json plugin metadata (Electrum reads this)
|
├── manifest.json plugin metadata (Electrum reads this)
|
||||||
├── qt.py Qt entry-point shim (re-exports Plugin)
|
├── 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)
|
├── core/ GUI-free logic (importable without Qt)
|
||||||
│ ├── util.py
|
│ ├── util.py
|
||||||
│ ├── plugin_base.py
|
│ ├── plugin_base.py
|
||||||
│ ├── heirs.py
|
│ ├── heirs.py
|
||||||
│ ├── will.py
|
│ ├── will.py
|
||||||
│ └── willexecutors.py
|
│ ├── willexecutors.py
|
||||||
|
│ ├── checkalive.py
|
||||||
|
│ ├── reminders.py
|
||||||
|
│ └── 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
|
├── gui/qt/ PyQt6 presentation layer
|
||||||
│ ├── theme.py status → color mapping
|
│ ├── theme.py status → color mapping
|
||||||
│ ├── common.py shared imports / helpers
|
│ ├── common.py shared imports / helpers
|
||||||
@@ -30,6 +39,7 @@ bal/ the installable Electrum plugin package
|
|||||||
│ ├── dialogs.py dialog windows
|
│ ├── dialogs.py dialog windows
|
||||||
│ ├── lists.py tree/list views
|
│ ├── lists.py tree/list views
|
||||||
│ ├── window.py per-wallet GUI controller
|
│ ├── window.py per-wallet GUI controller
|
||||||
|
│ ├── window_utils.py GUI utility helpers
|
||||||
│ └── plugin.py Plugin (Electrum @hooks → GUI)
|
│ └── plugin.py Plugin (Electrum @hooks → GUI)
|
||||||
├── icons/ wallet_util/ LICENSE README.md
|
├── icons/ wallet_util/ LICENSE README.md
|
||||||
build_zip.py builds a clean, zipimport-friendly distribution zip
|
build_zip.py builds a clean, zipimport-friendly distribution zip
|
||||||
@@ -77,6 +87,42 @@ Copy the `bal/` directory into your Electrum installation's
|
|||||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||||
exists, then enable it from **Tools → Plugins**.
|
exists, then enable it from **Tools → Plugins**.
|
||||||
|
|
||||||
|
## 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
|
## Inheritance safety: anticipate / postpone
|
||||||
|
|
||||||
A will transaction is signed with a **fixed, immutable locktime** and then
|
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)
|
lists.py Tree/list views (heirs, preview, will-executors)
|
||||||
window.py BalWindow controller (per-wallet GUI state)
|
window.py BalWindow controller (per-wallet GUI state)
|
||||||
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
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
|
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
|
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
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
|
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
|
``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
|
# (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).
|
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
||||||
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
# 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)
|
||||||
1246
bal/cli/controller.py
Normal file
1246
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)
|
||||||
@@ -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.
|
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 typing import Any
|
||||||
|
|
||||||
from .plugin_base import BalTimestamp
|
from .plugin_base import BalTimestamp
|
||||||
@@ -24,7 +24,7 @@ class CheckAliveError(Exception):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "Check alive expired please update it: {}".format(
|
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).
|
The reference timestamp (float, UNIX seconds).
|
||||||
"""
|
"""
|
||||||
if is_basic_mode:
|
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"])
|
threshold = BalTimestamp(will_settings["threshold"])
|
||||||
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
||||||
@@ -107,5 +107,5 @@ def check_alive_expired(
|
|||||||
"""
|
"""
|
||||||
if is_basic_mode:
|
if is_basic_mode:
|
||||||
return False
|
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
|
return date_to_check < current
|
||||||
|
|||||||
@@ -363,6 +363,10 @@ class Heirs(dict, Logger):
|
|||||||
Logger.__init__(self)
|
Logger.__init__(self)
|
||||||
self.db = wallet.db
|
self.db = wallet.db
|
||||||
self.wallet = wallet
|
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", {})
|
d = self.db.get("heirs", {})
|
||||||
try:
|
try:
|
||||||
self.update(d)
|
self.update(d)
|
||||||
@@ -630,6 +634,20 @@ class Heirs(dict, Logger):
|
|||||||
def buildTransactions(
|
def buildTransactions(
|
||||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
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())
|
_before = list(self.keys())
|
||||||
Heirs._validate(self, persist=False)
|
Heirs._validate(self, persist=False)
|
||||||
_removed = [k for k in _before if k not in self]
|
_removed = [k for k in _before if k not in self]
|
||||||
@@ -644,6 +662,7 @@ class Heirs(dict, Logger):
|
|||||||
", ".join(_removed),
|
", ".join(_removed),
|
||||||
)
|
)
|
||||||
if len(self) <= 0:
|
if len(self) <= 0:
|
||||||
|
self.last_build_error = "NO_HEIRS"
|
||||||
_logger.info("while building transactions there was no heirs")
|
_logger.info("while building transactions there was no heirs")
|
||||||
return
|
return
|
||||||
balance = 0.0
|
balance = 0.0
|
||||||
@@ -660,12 +679,18 @@ class Heirs(dict, Logger):
|
|||||||
len_utxo_set += 1
|
len_utxo_set += 1
|
||||||
available_utxos.append(utxo)
|
available_utxos.append(utxo)
|
||||||
if len_utxo_set == 0:
|
if len_utxo_set == 0:
|
||||||
|
self.last_build_error = "NO_UTXO"
|
||||||
_logger.info("no usable utxos")
|
_logger.info("no usable utxos")
|
||||||
return
|
return
|
||||||
j = -2
|
j = -2
|
||||||
willexecutorsitems = list(willexecutors.items())
|
willexecutorsitems = list(willexecutors.items())
|
||||||
willexecutorslen = len(willexecutorsitems)
|
willexecutorslen = len(willexecutorsitems)
|
||||||
alltxs = {}
|
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:
|
while True:
|
||||||
j += 1
|
j += 1
|
||||||
if j >= willexecutorslen:
|
if j >= willexecutorslen:
|
||||||
@@ -682,6 +707,7 @@ class Heirs(dict, Logger):
|
|||||||
url = willexecutor = None
|
url = willexecutor = None
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
processed_willexecutors += 1
|
||||||
fees = {}
|
fees = {}
|
||||||
i = 0
|
i = 0
|
||||||
txs = {}
|
txs = {}
|
||||||
@@ -699,9 +725,11 @@ class Heirs(dict, Logger):
|
|||||||
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
)
|
)
|
||||||
except WillExecutorFeeException:
|
except WillExecutorFeeException:
|
||||||
|
self.last_build_error = "WILLEXECUTOR_FEE"
|
||||||
i = 10
|
i = 10
|
||||||
continue
|
continue
|
||||||
except WillExecutorFeeTooHighException:
|
except WillExecutorFeeTooHighException:
|
||||||
|
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
|
||||||
i = 10
|
i = 10
|
||||||
continue
|
continue
|
||||||
if locktimes:
|
if locktimes:
|
||||||
@@ -710,19 +738,33 @@ class Heirs(dict, Logger):
|
|||||||
locktimes, available_utxos[:], fees, wallet
|
locktimes, available_utxos[:], fees, wallet
|
||||||
)
|
)
|
||||||
if not txs:
|
if not txs:
|
||||||
|
self.last_build_error = "TX_BUILD_FAILED"
|
||||||
return {}
|
return {}
|
||||||
except Exception as e:
|
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(
|
_logger.error(
|
||||||
f"build transactions: error preparing transactions: {e}"
|
"build transactions: error preparing transactions "
|
||||||
)
|
"for will-executor %s: %r",
|
||||||
try:
|
(willexecutor or {}).get("url", "(none)"),
|
||||||
if "w!ll3x3c" in e.heirname:
|
e,
|
||||||
Willexecutors.is_selected(
|
|
||||||
e.heirname[len("w!ll3x3c") :], False
|
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
total_fees = 0
|
total_fees = 0
|
||||||
total_fees_real = 0
|
total_fees_real = 0
|
||||||
total_in = 0
|
total_in = 0
|
||||||
@@ -746,12 +788,26 @@ class Heirs(dict, Logger):
|
|||||||
if i >= 10:
|
if i >= 10:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
self.last_build_error = "NO_FUTURE_DATE"
|
||||||
_logger.info(
|
_logger.info(
|
||||||
f"no locktimes for willexecutor {willexecutor} skipped"
|
f"no locktimes for willexecutor {willexecutor} skipped"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
alltxs.update(txs)
|
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
|
return alltxs
|
||||||
|
|
||||||
def get_transactions(
|
def get_transactions(
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from electrum import constants, json_db
|
from electrum import constants, json_db
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.plugin import BasePlugin
|
from electrum.plugin import BasePlugin
|
||||||
from electrum.transaction import tx_from_any
|
from electrum.transaction import tx_from_any
|
||||||
|
from electrum.util import classproperty
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -169,9 +170,12 @@ class BalPlugin(BasePlugin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||||
chainname = (
|
# Must be a classproperty (not a plain class attribute) because the class
|
||||||
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
# 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).
|
# Default geometry hint for some dialogs (kept from the original code).
|
||||||
SIZE = (159, 97)
|
SIZE = (159, 97)
|
||||||
@@ -251,6 +255,24 @@ class BalPlugin(BasePlugin):
|
|||||||
# (handled by BalWindow.get_wallet_password). Default ON.
|
# (handled by BalWindow.get_wallet_password). Default ON.
|
||||||
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
|
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
|
# EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
|
||||||
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
|
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
|
||||||
# not only inside the "Build your will" wizard. Default OFF, so the dates
|
# not only inside the "Build your will" wizard. Default OFF, so the dates
|
||||||
@@ -438,8 +460,8 @@ class BalPlugin(BasePlugin):
|
|||||||
def default_will_settings_absolute():
|
def default_will_settings_absolute():
|
||||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
"""Convert the default relative dates into absolute timestamps (from today)."""
|
||||||
relative_dates = BalPlugin.default_will_settings_relative()
|
relative_dates = BalPlugin.default_will_settings_relative()
|
||||||
today = date.today()
|
today = datetime.now(tz=timezone.utc).date()
|
||||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
threshold = (
|
threshold = (
|
||||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
||||||
).timestamp()
|
).timestamp()
|
||||||
@@ -499,12 +521,12 @@ class BalTimestamp:
|
|||||||
"""
|
"""
|
||||||
int32_max = 2 ** 31 - 1
|
int32_max = 2 ** 31 - 1
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(ts)
|
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
try:
|
try:
|
||||||
return datetime.fromtimestamp(min(int(ts), int32_max))
|
return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc)
|
||||||
except (OSError, OverflowError, ValueError):
|
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):
|
def to_date(self, from_date=None, reverse=False):
|
||||||
"""Resolve to a ``datetime``.
|
"""Resolve to a ``datetime``.
|
||||||
@@ -517,7 +539,7 @@ class BalTimestamp:
|
|||||||
return self._safe_fromtimestamp(self.value)
|
return self._safe_fromtimestamp(self.value)
|
||||||
else:
|
else:
|
||||||
if from_date is None:
|
if from_date is None:
|
||||||
from_date = datetime.now()
|
from_date = datetime.now(tz=timezone.utc)
|
||||||
if isinstance(from_date, (int, float)):
|
if isinstance(from_date, (int, float)):
|
||||||
from_date = self._safe_fromtimestamp(from_date)
|
from_date = self._safe_fromtimestamp(from_date)
|
||||||
reverse = 1 if not reverse else -1
|
reverse = 1 if not reverse else -1
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def compute_reminder_offsets(days, count):
|
|||||||
count: requested number of reminders.
|
count: requested number of reminders.
|
||||||
|
|
||||||
Returns:
|
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.
|
``days=30, count=3``. Empty if there is no room for any reminder.
|
||||||
"""
|
"""
|
||||||
# No room for any reminder (deadline today or already passed).
|
# No room for any reminder (deadline today or already passed).
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ original implementation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
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.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
from electrum.transaction import PartialTxOutput
|
from electrum.transaction import PartialTxOutput
|
||||||
@@ -103,7 +103,7 @@ class Util:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
now = datetime.now()
|
now = datetime.now(tz=timezone.utc)
|
||||||
if locktime[-1] == "y":
|
if locktime[-1] == "y":
|
||||||
locktime = str(int(locktime[:-1]) * 365) + "d"
|
locktime = str(int(locktime[:-1]) * 365) + "d"
|
||||||
if locktime[-1] == "d":
|
if locktime[-1] == "d":
|
||||||
@@ -189,7 +189,7 @@ class Util:
|
|||||||
# moment, so fall back to the legacy forward-from-now resolution.
|
# moment, so fall back to the legacy forward-from-now resolution.
|
||||||
return Util.parse_locktime_string(current)
|
return Util.parse_locktime_string(current)
|
||||||
try:
|
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
|
hour=0, minute=0, second=0, microsecond=0
|
||||||
)
|
)
|
||||||
build_moment = base - timedelta(days=built_days)
|
build_moment = base - timedelta(days=built_days)
|
||||||
@@ -440,9 +440,9 @@ class Util:
|
|||||||
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
||||||
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromtimestamp(locktime)
|
dt = datetime.fromtimestamp(locktime, tz=timezone.utc)
|
||||||
except (OverflowError, OSError, ValueError):
|
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)
|
dt -= timedelta(seconds=seconds)
|
||||||
out = dt.timestamp()
|
out = dt.timestamp()
|
||||||
|
|
||||||
@@ -450,34 +450,6 @@ class Util:
|
|||||||
out = 1
|
out = 1
|
||||||
return out
|
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
|
@staticmethod
|
||||||
def get_lowest_locktimes(locktimes):
|
def get_lowest_locktimes(locktimes):
|
||||||
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
||||||
@@ -492,32 +464,6 @@ class Util:
|
|||||||
|
|
||||||
return sorted(sorted_timestamp), sorted(sorted_block)
|
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
|
# UTXO helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ class Will:
|
|||||||
if not will[child[0]].father:
|
if not will[child[0]].father:
|
||||||
will[child[0]].father = willid
|
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
|
@staticmethod
|
||||||
def only_valid(will):
|
def only_valid(will):
|
||||||
for k, v in will.items():
|
for k, v in will.items():
|
||||||
@@ -107,15 +102,6 @@ class Will:
|
|||||||
and not w.get_status("CHECKED")
|
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
|
@staticmethod
|
||||||
def get_tx_from_any(x):
|
def get_tx_from_any(x):
|
||||||
try:
|
try:
|
||||||
@@ -516,6 +502,7 @@ class Will:
|
|||||||
for _wid, w in will.items():
|
for _wid, w in will.items():
|
||||||
if w.get_status("VALID") and not w.get_status("COMPLETE"):
|
if w.get_status("VALID") and not w.get_status("COMPLETE"):
|
||||||
return True
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_rai(all_inputs, all_utxos, will, wallet):
|
def search_rai(all_inputs, all_utxos, will, wallet):
|
||||||
@@ -1345,6 +1332,8 @@ class WillItem(Logger):
|
|||||||
WillItem,
|
WillItem,
|
||||||
):
|
):
|
||||||
self.__dict__ = w.__dict__.copy()
|
self.__dict__ = w.__dict__.copy()
|
||||||
|
self.STATUS = copy.deepcopy(w.STATUS)
|
||||||
|
self.heirs = copy.deepcopy(w.heirs) if w.heirs is not None else None
|
||||||
else:
|
else:
|
||||||
self.tx = Will.get_tx_from_any(w["tx"])
|
self.tx = Will.get_tx_from_any(w["tx"])
|
||||||
self.heirs = w.get("heirs", None)
|
self.heirs = w.get("heirs", None)
|
||||||
|
|||||||
@@ -112,8 +112,6 @@ def is_tor_active():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
chainname = BalPlugin.chainname
|
|
||||||
|
|
||||||
|
|
||||||
class Willexecutors:
|
class Willexecutors:
|
||||||
|
|
||||||
@@ -146,9 +144,9 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def save(bal_plugin, willexecutors):
|
def save(bal_plugin, willexecutors):
|
||||||
_logger.debug(f"save {willexecutors},{chainname}")
|
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
|
||||||
aw = bal_plugin.WILLEXECUTORS.get()
|
aw = bal_plugin.WILLEXECUTORS.get()
|
||||||
aw[chainname] = willexecutors
|
aw[BalPlugin.chainname] = willexecutors
|
||||||
bal_plugin.WILLEXECUTORS.set(aw)
|
bal_plugin.WILLEXECUTORS.set(aw)
|
||||||
_logger.debug(f"saved: {aw}")
|
_logger.debug(f"saved: {aw}")
|
||||||
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||||
@@ -158,7 +156,7 @@ class Willexecutors:
|
|||||||
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
|
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
|
||||||
):
|
):
|
||||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||||
willexecutors = willexecutors.get(chainname, {})
|
willexecutors = willexecutors.get(BalPlugin.chainname, {})
|
||||||
to_del = []
|
to_del = []
|
||||||
for w in willexecutors:
|
for w in willexecutors:
|
||||||
if not isinstance(willexecutors[w], dict):
|
if not isinstance(willexecutors[w], dict):
|
||||||
@@ -172,7 +170,7 @@ class Willexecutors:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
del willexecutors[w]
|
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():
|
for bal_url, bal_executor in bal.items():
|
||||||
if bal_url not in willexecutors:
|
if bal_url not in willexecutors:
|
||||||
_logger.debug(f"force add {bal_url} willexecutor")
|
_logger.debug(f"force add {bal_url} willexecutor")
|
||||||
@@ -368,7 +366,7 @@ class Willexecutors:
|
|||||||
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||||
if w := Willexecutors.send_request(
|
if w := Willexecutors.send_request(
|
||||||
"post",
|
"post",
|
||||||
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
willexecutor["url"] + "/" + BalPlugin.chainname + "/pushtxs",
|
||||||
data=willexecutor["txs"].encode("ascii"),
|
data=willexecutor["txs"].encode("ascii"),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
max_retries=max_retries,
|
max_retries=max_retries,
|
||||||
@@ -408,7 +406,7 @@ class Willexecutors:
|
|||||||
# single short timeout instead of retrying 10x with sleeps, which
|
# single short timeout instead of retrying 10x with sleeps, which
|
||||||
# used to freeze the UI for minutes per unreachable server.
|
# used to freeze the UI for minutes per unreachable server.
|
||||||
w = Willexecutors.send_request(
|
w = Willexecutors.send_request(
|
||||||
"get", url + "/" + chainname + "/info",
|
"get", url + "/" + BalPlugin.chainname + "/info",
|
||||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
)
|
)
|
||||||
if isinstance(w, dict):
|
if isinstance(w, dict):
|
||||||
@@ -788,7 +786,7 @@ class Willexecutors:
|
|||||||
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
||||||
willexecutors = Willexecutors.send_request(
|
willexecutors = Willexecutors.send_request(
|
||||||
"get",
|
"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):
|
if not isinstance(willexecutors, dict):
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
|||||||
the Qt button and the OS/subprocess glue.
|
the Qt button and the OS/subprocess glue.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QToolButton
|
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||||
|
|
||||||
|
from electrum.gui.qt.util import getSaveFileName
|
||||||
|
|
||||||
from ...core.reminders import write_temp_ics
|
from ...core.reminders import write_temp_ics
|
||||||
from .common import *
|
from .common import _, _logger
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
|
||||||
|
|
||||||
|
|
||||||
class BalCalendarButton(QToolButton):
|
class BalCalendarButton(QToolButton):
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ from ...core.heirs import (
|
|||||||
HEIR_DUST_AMOUNT,
|
HEIR_DUST_AMOUNT,
|
||||||
HEIR_REAL_AMOUNT,
|
HEIR_REAL_AMOUNT,
|
||||||
OP_RETURN_PREFIX,
|
OP_RETURN_PREFIX,
|
||||||
|
BalanceTooLowException,
|
||||||
HeirAmountIsDustException,
|
HeirAmountIsDustException,
|
||||||
Heirs,
|
Heirs,
|
||||||
WillExecutorFeeTooHighException,
|
WillExecutorFeeTooHighException,
|
||||||
|
|||||||
@@ -22,8 +22,62 @@ from typing import TYPE_CHECKING
|
|||||||
from ...core.checkalive import CheckAliveError
|
from ...core.checkalive import CheckAliveError
|
||||||
from ...core.reminders import build_ics_reminders
|
from ...core.reminders import build_ics_reminders
|
||||||
from .calendar import BalCalendarButton
|
from .calendar import BalCalendarButton
|
||||||
from .common import *
|
from .common import (
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
_,
|
||||||
|
_logger,
|
||||||
|
AmountException,
|
||||||
|
Any,
|
||||||
|
BalTimestamp,
|
||||||
|
BalanceTooLowException,
|
||||||
|
BestEffortRequestFailed,
|
||||||
|
Buttons,
|
||||||
|
Callable,
|
||||||
|
CancelButton,
|
||||||
|
HEIR_DUST_AMOUNT,
|
||||||
|
HEIR_REAL_AMOUNT,
|
||||||
|
HeirAmountIsDustException,
|
||||||
|
HeirChangeException,
|
||||||
|
HeirNotFoundException,
|
||||||
|
MessageBoxMixin,
|
||||||
|
Network,
|
||||||
|
NoHeirsException,
|
||||||
|
NoWillExecutorNotPresent,
|
||||||
|
NotCompleteWillException,
|
||||||
|
QComboBox,
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
|
QTimer,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
Qt,
|
||||||
|
TaskThread,
|
||||||
|
TxBroadcastError,
|
||||||
|
TxFeesChangedException,
|
||||||
|
Util,
|
||||||
|
Will,
|
||||||
|
WillExecutorFeeTooHighException,
|
||||||
|
WillExecutorNotPresent,
|
||||||
|
WillExpiredException,
|
||||||
|
WillPostponedException,
|
||||||
|
WillexecutorChangeException,
|
||||||
|
Willexecutors,
|
||||||
|
bring_to_front,
|
||||||
|
decimal_point_to_base_unit_name,
|
||||||
|
import_meta_gui,
|
||||||
|
partial,
|
||||||
|
pyqtSignal,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
read_json_file,
|
||||||
|
show_modal,
|
||||||
|
show_on_top,
|
||||||
|
stop_thread,
|
||||||
|
time,
|
||||||
|
top_level_of,
|
||||||
|
)
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
WillSettingsWidget,
|
WillSettingsWidget,
|
||||||
WillWidget,
|
WillWidget,
|
||||||
@@ -637,14 +691,20 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
_logger.debug(
|
_logger.debug(
|
||||||
"during phase1 CAE: {}, Continue to invalidate".format(cae)
|
"during phase1 CAE: {}, Continue to invalidate".format(cae)
|
||||||
)
|
)
|
||||||
self.msg_set_status("Checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR)
|
self.msg_set_status(
|
||||||
|
"Checking variables", varrow,
|
||||||
|
"Check Alive Threshold Passed: you have to Invalidate "
|
||||||
|
"your old Will",
|
||||||
|
self.COLOR_ERROR,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise cae
|
raise cae
|
||||||
return None, tx
|
return None, tx
|
||||||
except NoHeirsException:
|
except NoHeirsException:
|
||||||
self.msg_set_status("Checking variables", varrow,"No Heirs",self.COLOR_ERROR)
|
self.msg_set_status(
|
||||||
#self.msg_set_checking("No Heirs")
|
"Checking variables", varrow, self.msg_alert("No Heirs")
|
||||||
return False, None
|
)
|
||||||
|
return "no_heirs", None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
try:
|
try:
|
||||||
@@ -759,61 +819,29 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
_("Inheritance in mempool (waiting confirmation)"),
|
_("Inheritance in mempool (waiting confirmation)"),
|
||||||
self.COLOR_WARNING,
|
self.COLOR_WARNING,
|
||||||
)
|
)
|
||||||
if isinstance(e, HeirChangeException):
|
# All of these situations used to collapse into the SAME sentence
|
||||||
message = _("Heirs changed:")
|
# ("Found CHANGES to the DATE or the HEIRS, a NEW WILL must be
|
||||||
elif isinstance(e, WillExecutorNotPresent):
|
# prepared"), shown for five genuinely different causes - and
|
||||||
message = _("Will-Executor not present")
|
# plainly WRONG for the most common one, receiving funds, where
|
||||||
elif isinstance(e, WillexecutorChangeException):
|
# neither the date nor the heirs changed. _check_failure_message
|
||||||
message = _("Will-Executor changed")
|
# names the real cause using the detail each exception already
|
||||||
elif isinstance(e, TxFeesChangedException):
|
# carries (heir name, will-executor URL, old/new fee rate).
|
||||||
message = _("Txfees are changed")
|
message = self._check_failure_message(e)
|
||||||
elif isinstance(e, HeirNotFoundException):
|
|
||||||
# Task #01b: the old text "Heir not found" was misleading.
|
|
||||||
# In practice this branch is reached whenever the will is no
|
|
||||||
# longer coherent and must be rebuilt - very often simply
|
|
||||||
# because the delivery date was anticipated, NOT because an heir
|
|
||||||
# is genuinely missing. We therefore show a clear, accurate
|
|
||||||
# message that covers both the DATE and the HEIRS cases.
|
|
||||||
message = _(
|
|
||||||
"Found CHANGES to the DATE or the HEIRS,\n"
|
|
||||||
"a NEW WILL must be prepared."
|
|
||||||
)
|
|
||||||
if message:
|
|
||||||
_logger.debug(f"message: {message}")
|
_logger.debug(f"message: {message}")
|
||||||
self.msg_set_checking(message)
|
self.msg_set_checking(message)
|
||||||
else:
|
|
||||||
# Task #01b: the old fallback text "New" was unclear. When the
|
|
||||||
# will is incomplete without a more specific reason, it still
|
|
||||||
# means the will has to be rebuilt, so we use the same clear
|
|
||||||
# message as the HeirNotFoundException branch above.
|
|
||||||
self.msg_set_checking(
|
|
||||||
_(
|
|
||||||
"Found CHANGES to the DATE or the HEIRS,\n"
|
|
||||||
"a NEW WILL must be prepared."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if have_to_build:
|
if have_to_build:
|
||||||
self.msg_set_building()
|
self.msg_set_building()
|
||||||
try:
|
try:
|
||||||
txs = self.bal_window.build_will()
|
txs = self.bal_window.build_will()
|
||||||
if not txs:
|
if not txs:
|
||||||
|
# The message now names the ACTUAL reason the build gave
|
||||||
|
# up (recorded by Heirs.buildTransactions) instead of
|
||||||
|
# listing three fixed guesses that were frequently all
|
||||||
|
# wrong. msg_alert keeps the warning sign coloured and the
|
||||||
|
# text in the default colour so it stays readable.
|
||||||
self.msg_set_building(
|
self.msg_set_building(
|
||||||
_(
|
self.msg_alert(self._build_failure_message())
|
||||||
"Could not build the will ! Possible reasons:\n"
|
|
||||||
"1- the Balance of wallet is too low to cover the "
|
|
||||||
"fees for miners and will executors,\n"
|
|
||||||
"2- the Heirs' shares are below the minimum (Dust "
|
|
||||||
"UTXO, less than 546 Satoshi),\n"
|
|
||||||
"3- the Check Alive Date/Time is later than the "
|
|
||||||
"delivery time (it must be earlier),\n"
|
|
||||||
"Skipped"
|
|
||||||
),
|
|
||||||
# Orange (warning) instead of red (error): an empty
|
|
||||||
# wallet after the inheritance was executed is a NORMAL
|
|
||||||
# situation, not a failure, so the colour should not
|
|
||||||
# alarm the user (owner request).
|
|
||||||
color=self.COLOR_WARNING,
|
|
||||||
)
|
)
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
@@ -898,6 +926,28 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
)
|
)
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
except BalanceTooLowException as e:
|
||||||
|
# The core DOES detect this precisely and carries the numbers,
|
||||||
|
# but the exception was never caught here: it fell through to
|
||||||
|
# the generic handler below, which printed the raw technical
|
||||||
|
# string in red and re-raised. Show the real figures instead.
|
||||||
|
self.msg_set_building(
|
||||||
|
self.msg_alert(
|
||||||
|
_(
|
||||||
|
"Wallet balance is too low: {} satoshi available, "
|
||||||
|
"but the miner and will-executor fees need {} "
|
||||||
|
"satoshi (the minimum usable amount is {} "
|
||||||
|
"satoshi). Add funds, or select fewer "
|
||||||
|
"will-executors."
|
||||||
|
).format(
|
||||||
|
int(e.balance), int(e.fees), int(e.dust_threshold)
|
||||||
|
)
|
||||||
|
+ "\n\n"
|
||||||
|
+ _("Skipped")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return False, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.msg_set_building(self.msg_error(e))
|
self.msg_set_building(self.msg_error(e))
|
||||||
raise e
|
raise e
|
||||||
@@ -1439,6 +1489,10 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
self._add_no_willexecutor_buttons()
|
self._add_no_willexecutor_buttons()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if self.have_to_sign == "no_heirs":
|
||||||
|
self._add_no_heirs_buttons()
|
||||||
|
return
|
||||||
|
|
||||||
_logger.debug("have to sign {}".format(self.have_to_sign))
|
_logger.debug("have to sign {}".format(self.have_to_sign))
|
||||||
password = None
|
password = None
|
||||||
if self.have_to_sign is None:
|
if self.have_to_sign is None:
|
||||||
@@ -1646,6 +1700,70 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
on_error=self.on_error_phase1,
|
on_error=self.on_error_phase1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# No-heirs error handling (mirrors the no-willexecutor pattern above)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _add_no_heirs_buttons(self):
|
||||||
|
"""Add "Heirs", "Wizard" and "Close" buttons when no heirs are
|
||||||
|
configured."""
|
||||||
|
if getattr(self, "_no_heirs_buttons_added", False):
|
||||||
|
return
|
||||||
|
self._no_heirs_buttons_added = True
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch(1)
|
||||||
|
|
||||||
|
heirs_btn = QPushButton(_("Heirs"))
|
||||||
|
heirs_btn.clicked.connect(self._open_heir_dialog)
|
||||||
|
btn_row.addWidget(heirs_btn)
|
||||||
|
|
||||||
|
wizard_btn = QPushButton(_("\U0001f52e Wizard"))
|
||||||
|
wizard_btn.clicked.connect(self._open_heirs_wizard)
|
||||||
|
btn_row.addWidget(wizard_btn)
|
||||||
|
|
||||||
|
close_btn = QPushButton(_("Close"))
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
btn_row.addWidget(close_btn)
|
||||||
|
|
||||||
|
self._no_heirs_layout = btn_row
|
||||||
|
self.vbox.addLayout(btn_row)
|
||||||
|
self.resize(self.vbox.sizeHint())
|
||||||
|
|
||||||
|
def _open_heir_dialog(self):
|
||||||
|
"""Open the heirs management dialog, then retry the build."""
|
||||||
|
d = HeirsDialog(self.bal_window, parent=self)
|
||||||
|
d.exec()
|
||||||
|
self._retry_build_after_heirs()
|
||||||
|
|
||||||
|
def _open_heirs_wizard(self):
|
||||||
|
"""Close the build-will dialog and open the wizard at the heirs
|
||||||
|
step so the user can add heirs."""
|
||||||
|
self.close()
|
||||||
|
wizard = BalWizardDialog(self.bal_window)
|
||||||
|
wizard.exec()
|
||||||
|
|
||||||
|
def _retry_build_after_heirs(self):
|
||||||
|
"""Remove the no-heirs buttons, reset the message panel,
|
||||||
|
and re-run ``task_phase1`` on the same thread."""
|
||||||
|
self._no_heirs_buttons_added = False
|
||||||
|
if self._no_heirs_layout:
|
||||||
|
while self._no_heirs_layout.count():
|
||||||
|
item = self._no_heirs_layout.takeAt(0)
|
||||||
|
w = item.widget()
|
||||||
|
if w:
|
||||||
|
w.setParent(None)
|
||||||
|
w.deleteLater()
|
||||||
|
self.vbox.removeItem(self._no_heirs_layout)
|
||||||
|
self._no_heirs_layout = None
|
||||||
|
self.labels = []
|
||||||
|
self.msg_update()
|
||||||
|
self.thread.add(
|
||||||
|
self.task_phase1,
|
||||||
|
on_success=self.on_success_phase1,
|
||||||
|
on_done=self.on_accept,
|
||||||
|
on_error=self.on_error_phase1,
|
||||||
|
)
|
||||||
|
|
||||||
def _ics_provider(self):
|
def _ics_provider(self):
|
||||||
"""Return the .ics content for the current will data."""
|
"""Return the .ics content for the current will data."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -1889,6 +2007,168 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
_logger.debug(f"_executed_inheritance_status error: {_err}")
|
_logger.debug(f"_executed_inheritance_status error: {_err}")
|
||||||
return "MEMPOOL" if has_mempool else None
|
return "MEMPOOL" if has_mempool else None
|
||||||
|
|
||||||
|
def _check_failure_message(self, e):
|
||||||
|
"""Return a precise explanation of why the will is no longer coherent.
|
||||||
|
|
||||||
|
``Will.is_will_valid`` raises NotCompleteWillException (or one of its
|
||||||
|
subclasses) when the stored will stops matching the wallet, the heirs
|
||||||
|
or the will-executors. Those exceptions ALREADY carry the useful
|
||||||
|
detail - the heir name, the will-executor URL, the old and new fee
|
||||||
|
rate - but this dialog used to discard all of it and print the same
|
||||||
|
sentence, "Found CHANGES to the DATE or the HEIRS", for every case.
|
||||||
|
|
||||||
|
That was not merely vague, it was WRONG for the most common situation:
|
||||||
|
when the wallet simply receives new funds, neither the date nor the
|
||||||
|
heirs changed, yet the user was sent hunting for edits never made.
|
||||||
|
|
||||||
|
NOTE: no new exception classes were added for this (owner request).
|
||||||
|
Every case below is told apart using only what the core already
|
||||||
|
raises today.
|
||||||
|
"""
|
||||||
|
# Subclasses first - they are all NotCompleteWillException.
|
||||||
|
if isinstance(e, TxFeesChangedException):
|
||||||
|
# The core raises TxFeesChangedException(f"{tx_fees}: {w.tx_fees}"),
|
||||||
|
# i.e. "current: stored". Show both rates when they can be read,
|
||||||
|
# and fall back to a plain sentence if that format ever changes.
|
||||||
|
try:
|
||||||
|
now_fee, old_fee = [p.strip() for p in str(e).split(":", 1)]
|
||||||
|
return _(
|
||||||
|
"Miner fee rate changed (the will was built with {} "
|
||||||
|
"sat/byte, now it is {}): a new will must be prepared."
|
||||||
|
).format(old_fee, now_fee)
|
||||||
|
except Exception:
|
||||||
|
return _(
|
||||||
|
"The miner fee rate changed: a new will must be prepared."
|
||||||
|
)
|
||||||
|
if isinstance(e, WillExecutorNotPresent):
|
||||||
|
return _(
|
||||||
|
'Will-executor "{}" is not covered by the current will: '
|
||||||
|
"a new will must be prepared."
|
||||||
|
).format(str(e))
|
||||||
|
if isinstance(e, NoWillExecutorNotPresent):
|
||||||
|
return _(
|
||||||
|
"Backup mode is enabled but the will has no backup "
|
||||||
|
"transaction: a new will must be prepared."
|
||||||
|
)
|
||||||
|
if isinstance(e, HeirNotFoundException):
|
||||||
|
# Raised when an heir was added, removed, or had its delivery date
|
||||||
|
# changed. We deliberately do NOT try to tell those three apart
|
||||||
|
# (owner request: too fine-grained); naming the heir is what makes
|
||||||
|
# the message actionable.
|
||||||
|
return _(
|
||||||
|
'Heir "{}" is not covered by the current will (it was added '
|
||||||
|
"or removed, or its delivery date changed): a new will must "
|
||||||
|
"be prepared."
|
||||||
|
).format(str(e))
|
||||||
|
# Kept for completeness: nothing in the plugin raises these two today,
|
||||||
|
# but they ARE NotCompleteWillException subclasses, so should future
|
||||||
|
# code raise them they get a sensible message rather than the fallback.
|
||||||
|
if isinstance(e, HeirChangeException):
|
||||||
|
return _("The heirs changed: a new will must be prepared.")
|
||||||
|
if isinstance(e, WillexecutorChangeException):
|
||||||
|
return _("A will-executor changed: a new will must be prepared.")
|
||||||
|
|
||||||
|
# A plain NotCompleteWillException. The core raises it in exactly two
|
||||||
|
# places, told apart STRUCTURALLY (not by matching message text, which
|
||||||
|
# would be fragile): with no argument when the will holds no valid
|
||||||
|
# transaction, and with one argument when a wallet utxo is not
|
||||||
|
# included in the will.
|
||||||
|
if type(e) is NotCompleteWillException:
|
||||||
|
if e.args:
|
||||||
|
return _(
|
||||||
|
"The wallet contains funds that the current will does not "
|
||||||
|
"cover yet: a new will must be prepared."
|
||||||
|
)
|
||||||
|
return _(
|
||||||
|
"The will contains no valid transaction: a new will must be "
|
||||||
|
"prepared."
|
||||||
|
)
|
||||||
|
|
||||||
|
# An unrecognised subclass: say so honestly instead of guessing.
|
||||||
|
return _(
|
||||||
|
"The will is no longer coherent and must be rebuilt; the exact "
|
||||||
|
"reason could not be determined."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_failure_message(self):
|
||||||
|
"""Return a plain-language explanation of why the will was not built.
|
||||||
|
|
||||||
|
``Heirs.buildTransactions`` records a reason code in
|
||||||
|
``last_build_error`` every time it gives up (the codes are listed in
|
||||||
|
that method). Here we turn that code into ONE specific sentence
|
||||||
|
telling the user what to fix.
|
||||||
|
|
||||||
|
WHY: this dialog used to print the same three "possible reasons"
|
||||||
|
(low balance / dust shares / check-alive after the delivery date)
|
||||||
|
whenever the build returned nothing. The owner hit a real case where
|
||||||
|
all three were false - the actual cause was that no will-executor was
|
||||||
|
usable, which the list did not even mention - so the message actively
|
||||||
|
misled. When the reason is unknown we now SAY that it is unknown and
|
||||||
|
list what to check, instead of asserting three guesses as if they were
|
||||||
|
the only possibilities.
|
||||||
|
"""
|
||||||
|
reason = None
|
||||||
|
try:
|
||||||
|
reason = getattr(self.bal_window.heirs, "last_build_error", None)
|
||||||
|
except Exception as _err:
|
||||||
|
# A diagnostic must never break the report it is explaining.
|
||||||
|
_logger.debug(f"_build_failure_message: {_err}")
|
||||||
|
|
||||||
|
messages = {
|
||||||
|
"NO_HEIRS": _(
|
||||||
|
"No heirs: add at least one heir before building the will."
|
||||||
|
),
|
||||||
|
"NO_UTXO": _(
|
||||||
|
"The wallet has no spendable funds, so no inheritance "
|
||||||
|
"transaction can be created."
|
||||||
|
),
|
||||||
|
"NO_WILLEXECUTOR_USABLE": _(
|
||||||
|
"No usable will-executor: none of the servers in the list is "
|
||||||
|
"both selected and valid. Open the will-executor settings, "
|
||||||
|
"select at least one server and check that it is reachable."
|
||||||
|
),
|
||||||
|
"NO_FUTURE_DATE": _(
|
||||||
|
"No delivery date left to build: every heir's date is already "
|
||||||
|
"covered by the existing will. Choose a later delivery date, "
|
||||||
|
"or change an heir's date."
|
||||||
|
),
|
||||||
|
"WILLEXECUTOR_FEE": _(
|
||||||
|
"The amount to send must cover the miner fees plus this "
|
||||||
|
"will-executor's fee, and the wallet balance is not enough: "
|
||||||
|
"select cheaper will-executors, or add funds to the wallet."
|
||||||
|
),
|
||||||
|
"WILLEXECUTOR_FEE_TOO_HIGH": _(
|
||||||
|
"A will-executor asks for more than the maximum fee you "
|
||||||
|
"allowed: raise the maximum fee in the settings, or select a "
|
||||||
|
"cheaper will-executor."
|
||||||
|
),
|
||||||
|
"TX_BUILD_FAILED": _(
|
||||||
|
"The inheritance transactions could not be assembled from the "
|
||||||
|
"available funds (the balance may not cover the miner fees)."
|
||||||
|
),
|
||||||
|
"WILLEXECUTOR_TX_ERROR": _(
|
||||||
|
"An unexpected error stopped the transactions being prepared "
|
||||||
|
"for a will-executor, so it was skipped. Try again, or select "
|
||||||
|
"a different will-executor."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if reason in messages:
|
||||||
|
return messages[reason] + "\n\n" + _("Skipped")
|
||||||
|
|
||||||
|
return (
|
||||||
|
_(
|
||||||
|
"Could not build the will, and the exact cause could not be "
|
||||||
|
"determined. Please check that:\n"
|
||||||
|
"- the wallet balance covers the miner and will-executor fees,\n"
|
||||||
|
"- each heir's share is above the minimum (dust limit),\n"
|
||||||
|
"- the Check Alive date is EARLIER than the delivery date,\n"
|
||||||
|
"- at least one will-executor is selected and reachable."
|
||||||
|
)
|
||||||
|
+ "\n\n"
|
||||||
|
+ _("Skipped")
|
||||||
|
)
|
||||||
|
|
||||||
def msg_set_checking(self, status="Waiting", row=None):
|
def msg_set_checking(self, status="Waiting", row=None):
|
||||||
row = self.check_row if row is None else row
|
row = self.check_row if row is None else row
|
||||||
self.check_row = self.msg_set_status(_("Checking your will"), row, status)
|
self.check_row = self.msg_set_status(_("Checking your will"), row, status)
|
||||||
@@ -1932,6 +2212,22 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
# Results are shown in bold (see msg_error).
|
# Results are shown in bold (see msg_error).
|
||||||
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_WARNING, e)
|
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_WARNING, e)
|
||||||
|
|
||||||
|
def msg_alert(self, e):
|
||||||
|
"""Amber warning sign followed by text in the theme's default colour.
|
||||||
|
|
||||||
|
WHY: long warnings printed entirely in amber (COLOR_WARNING) are hard
|
||||||
|
to read - the owner reported the multi-line "could not build the will"
|
||||||
|
block as barely legible. Colour is only needed to ATTRACT attention,
|
||||||
|
not to be read, so we keep it on the "warning sign" character alone and
|
||||||
|
let the message body inherit Electrum's normal text colour. That also
|
||||||
|
keeps it readable under the dark theme, where a hard-coded black would
|
||||||
|
disappear. U+26A0 is written as a numeric HTML entity so the source
|
||||||
|
file stays pure ASCII; QLabel renders it as rich text.
|
||||||
|
"""
|
||||||
|
return "<font color='{}'>⚠</font> <b>{}</b>".format(
|
||||||
|
self.COLOR_WARNING, e
|
||||||
|
)
|
||||||
|
|
||||||
def msg_set_status(self, msg, row=None, status=None, color=None):
|
def msg_set_status(self, msg, row=None, status=None, color=None):
|
||||||
# The left "state" label keeps its normal weight; only the right-side
|
# The left "state" label keeps its normal weight; only the right-side
|
||||||
# result (``status``) is rendered in bold so it is easy to read at a
|
# result (``status``) is rendered in bold so it is easy to read at a
|
||||||
@@ -2197,3 +2493,49 @@ class WillExecutorDialog(BalDialog, MessageBoxMixin):
|
|||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
|
class HeirsDialog(BalDialog, MessageBoxMixin):
|
||||||
|
def __init__(self, bal_window, parent=None):
|
||||||
|
if not parent:
|
||||||
|
parent = bal_window.window
|
||||||
|
BalDialog.__init__(self, parent, bal_window.bal_plugin)
|
||||||
|
self.bal_plugin = bal_window.bal_plugin
|
||||||
|
self.bal_window = bal_window
|
||||||
|
|
||||||
|
self.setWindowTitle(_("Heirs"))
|
||||||
|
self.setMinimumSize(800, 300)
|
||||||
|
|
||||||
|
from .lists import HeirListWidget
|
||||||
|
vbox = QVBoxLayout(self)
|
||||||
|
self.heir_list_widget = HeirListWidget(bal_window, self)
|
||||||
|
vbox.addWidget(self.heir_list_widget)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
new_heir_btn = QPushButton(_("New Heir"))
|
||||||
|
new_heir_btn.clicked.connect(self._add_heir)
|
||||||
|
btn_row.addWidget(new_heir_btn)
|
||||||
|
|
||||||
|
import_btn = QPushButton(_("Import"))
|
||||||
|
import_btn.clicked.connect(self._import_heirs)
|
||||||
|
btn_row.addWidget(import_btn)
|
||||||
|
|
||||||
|
export_btn = QPushButton(_("Export"))
|
||||||
|
export_btn.clicked.connect(self._export_heirs)
|
||||||
|
btn_row.addWidget(export_btn)
|
||||||
|
|
||||||
|
btn_row.addStretch(1)
|
||||||
|
vbox.addLayout(btn_row)
|
||||||
|
|
||||||
|
def _add_heir(self):
|
||||||
|
self.bal_window.new_heir_dialog()
|
||||||
|
self.heir_list_widget.update()
|
||||||
|
|
||||||
|
def _import_heirs(self):
|
||||||
|
self.bal_window.import_heirs()
|
||||||
|
self.heir_list_widget.update()
|
||||||
|
|
||||||
|
def _export_heirs(self):
|
||||||
|
self.bal_window.export_heirs()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,59 @@ from typing import TYPE_CHECKING
|
|||||||
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||||
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||||
|
|
||||||
from .common import *
|
from .common import (
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
_,
|
||||||
|
_logger,
|
||||||
|
BalTimestamp,
|
||||||
|
Buttons,
|
||||||
|
CancelButton,
|
||||||
|
HelpButton,
|
||||||
|
MessageBoxMixin,
|
||||||
|
MyTreeView,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
|
OkButton,
|
||||||
|
QAbstractItemView,
|
||||||
|
QApplication,
|
||||||
|
QColor,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMenu,
|
||||||
|
QModelIndex,
|
||||||
|
QPersistentModelIndex,
|
||||||
|
QPushButton,
|
||||||
|
QSize,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStandardItem,
|
||||||
|
QStandardItemModel,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
Qt,
|
||||||
|
TaskThread,
|
||||||
|
Util,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
Willexecutors,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
datetime,
|
||||||
|
enum,
|
||||||
|
export_meta_gui,
|
||||||
|
getOpenFileName,
|
||||||
|
import_meta_gui,
|
||||||
|
is_op_return_address,
|
||||||
|
partial,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
read_json_file,
|
||||||
|
server_status_text,
|
||||||
|
server_status_tooltip,
|
||||||
|
signature_suffix,
|
||||||
|
status_color,
|
||||||
|
tx_from_any,
|
||||||
|
write_json_file,
|
||||||
|
)
|
||||||
from .dialogs import BalBuildWillDialog, BalDialog
|
from .dialogs import BalBuildWillDialog, BalDialog
|
||||||
from .widgets import BalCheckBox, WillSettingsWidget
|
from .widgets import BalCheckBox, WillSettingsWidget
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,35 @@ and cached in ``self.bal_windows``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from electrum.gui.qt.main_window import StatusBarButton
|
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 PyQt6.QtWidgets import QLayout
|
||||||
|
|
||||||
from .common import *
|
from .common import (
|
||||||
from .common import ( # underscore names are not re-exported by "import *"
|
|
||||||
_,
|
_,
|
||||||
_logger,
|
_logger,
|
||||||
|
BalPlugin,
|
||||||
|
Buttons,
|
||||||
|
EnterButton,
|
||||||
|
HelpButton,
|
||||||
|
PasswordDialog,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTimer,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
UserCancelled,
|
||||||
|
Willexecutors,
|
||||||
|
add_widget,
|
||||||
|
partial,
|
||||||
read_QIcon_from_bytes,
|
read_QIcon_from_bytes,
|
||||||
|
read_QPixmap_from_bytes,
|
||||||
|
show_modal,
|
||||||
|
webopen,
|
||||||
)
|
)
|
||||||
from .dialogs import BalDialog
|
from .dialogs import BalDialog
|
||||||
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
|
||||||
@@ -40,7 +62,7 @@ def _window_key(window):
|
|||||||
return id(window)
|
return id(window)
|
||||||
|
|
||||||
|
|
||||||
class Plugin(BalPlugin):
|
class Plugin(BalPlugin, EventListener):
|
||||||
def __init__(self, parent, config, name):
|
def __init__(self, parent, config, name):
|
||||||
_logger.info("INIT BALPLUGIN")
|
_logger.info("INIT BALPLUGIN")
|
||||||
BalPlugin.__init__(self, parent, config, name)
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
@@ -49,6 +71,10 @@ class Plugin(BalPlugin):
|
|||||||
# remove a stale button before creating a fresh one when a wallet is
|
# remove a stale button before creating a fresh one when a wallet is
|
||||||
# switched / Electrum is restarted, so the icon is never duplicated.
|
# switched / Electrum is restarted, so the icon is never duplicated.
|
||||||
self._statusbar_buttons = {}
|
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
|
@hook
|
||||||
def init_qt(self, gui_object):
|
def init_qt(self, gui_object):
|
||||||
@@ -328,6 +354,44 @@ class Plugin(BalPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("close_wallet: on_close failed: {}".format(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
|
@hook
|
||||||
def init_keystore(self):
|
def init_keystore(self):
|
||||||
_logger.debug("init keystore")
|
_logger.debug("init keystore")
|
||||||
@@ -448,13 +512,24 @@ class Plugin(BalPlugin):
|
|||||||
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
||||||
)
|
)
|
||||||
|
|
||||||
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
|
# "Rebuild will on wallet close" checkbox. Bound to the persisted
|
||||||
# config (default ON, see plugin_base.py), the SAME config used by the
|
# REBUILD_ON_CLOSE config (default ON). When ticked, closing the wallet
|
||||||
# checkbox inside the "Build your will" wizard's will-executor download
|
# / quitting Electrum runs the "Build your will" wizard to rebuild and
|
||||||
# window, so the two stay in sync automatically. When enabled the plugin
|
# re-validate the will. When unticked, the will is only rebuilt when
|
||||||
# also builds a will that does not require a will-executor (e.g. it can
|
# the user presses Check/Prepare. Visible to all users (BASIC and
|
||||||
# be saved on a USB stick and a copy given to the heirs).
|
# ADVANCED).
|
||||||
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
|
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)
|
||||||
|
|
||||||
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||||
# (not a free-text field) bound to the USER_TYPE config:
|
# (not a free-text field) bound to the USER_TYPE config:
|
||||||
@@ -642,35 +717,13 @@ class Plugin(BalPlugin):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
|
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
|
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
|
||||||
# will-executor. Visible to all users (BASIC and ADVANCED).
|
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Max Will-Executor Fee (satoshi)",
|
"Max Will-Executor Fee (satoshi)",
|
||||||
heir_max_willexecutor_fee,
|
heir_max_willexecutor_fee,
|
||||||
5,
|
4,
|
||||||
(
|
(
|
||||||
"Maximum fee (in satoshi) allowed to be paid to a single "
|
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||||
"will-executor. If a will-executor charges more than this, "
|
"will-executor. If a will-executor charges more than this, "
|
||||||
@@ -678,14 +731,14 @@ class Plugin(BalPlugin):
|
|||||||
"Default: 500,000 satoshi (0.005 BTC)."
|
"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 Type selector placed BEFORE the advanced-only settings so the
|
||||||
# user chooses basic/advanced first, then sees the relevant options.
|
# user chooses basic/advanced first, then sees the relevant options.
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"User Type",
|
"User Type",
|
||||||
user_type_combo,
|
user_type_combo,
|
||||||
6,
|
5,
|
||||||
(
|
(
|
||||||
"Choose how much detail the plugin shows.\n\n"
|
"Choose how much detail the plugin shows.\n\n"
|
||||||
"BASIC: simplified interface, safe configuration for most "
|
"BASIC: simplified interface, safe configuration for most "
|
||||||
@@ -696,7 +749,7 @@ class Plugin(BalPlugin):
|
|||||||
"editable."
|
"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
|
# Number of reminders, event summary and event description are visible
|
||||||
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
# only in ADVANCED mode. In BASIC mode the factory defaults are always
|
||||||
# used and these settings are hidden.
|
# used and these settings are hidden.
|
||||||
@@ -705,11 +758,11 @@ class Plugin(BalPlugin):
|
|||||||
"How many reminder alarms the exported calendar (.ics) event "
|
"How many reminder alarms the exported calendar (.ics) event "
|
||||||
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
"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(lbl_num_reminders), 6, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
|
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
|
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
||||||
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
|
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"))
|
lbl_event_summary = QLabel(_("Event summary"))
|
||||||
help_event_summary = HelpButton(
|
help_event_summary = HelpButton(
|
||||||
@@ -719,11 +772,11 @@ class Plugin(BalPlugin):
|
|||||||
" $heirs_complete: list of heirs name,address,amount\n"
|
" $heirs_complete: list of heirs name,address,amount\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
)
|
||||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
|
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
|
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
||||||
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
|
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"))
|
lbl_event_description = QLabel(_("Event description"))
|
||||||
help_event_description = HelpButton(
|
help_event_description = HelpButton(
|
||||||
@@ -733,11 +786,11 @@ class Plugin(BalPlugin):
|
|||||||
" $heirs_complete: list of heirs name,address,amount\n"
|
" $heirs_complete: list of heirs name,address,amount\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
)
|
||||||
grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
|
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
|
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
||||||
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
|
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
|
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the
|
||||||
# factory default is always used and the setting is hidden.
|
# factory default is always used and the setting is hidden.
|
||||||
lbl_welist_server = QLabel(_("Welist Server URL"))
|
lbl_welist_server = QLabel(_("Welist Server URL"))
|
||||||
@@ -745,11 +798,11 @@ class Plugin(BalPlugin):
|
|||||||
"URL of the server that provides the will-executor list. "
|
"URL of the server that provides the will-executor list. "
|
||||||
"Only available in ADVANCED mode."
|
"Only available in ADVANCED mode."
|
||||||
)
|
)
|
||||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0)
|
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
|
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
|
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
||||||
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
|
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"))
|
lbl_calendar_app = QLabel(_("Calendar app command"))
|
||||||
help_calendar_app = HelpButton(
|
help_calendar_app = HelpButton(
|
||||||
@@ -757,11 +810,11 @@ class Plugin(BalPlugin):
|
|||||||
"Leave empty to use the system default (xdg-open/open/start).\n"
|
"Leave empty to use the system default (xdg-open/open/start).\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
)
|
||||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0)
|
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
|
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
|
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2)
|
||||||
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
|
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
|
# Save-in-history toggle and history label: advanced-only rows. The
|
||||||
# label field is disabled while the checkbox is off (see
|
# label field is disabled while the checkbox is off (see
|
||||||
@@ -774,11 +827,11 @@ class Plugin(BalPlugin):
|
|||||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
)
|
||||||
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
|
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
|
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
|
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
|
||||||
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
|
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"))
|
lbl_history_label = QLabel(_("History label"))
|
||||||
help_history_label = HelpButton(
|
help_history_label = HelpButton(
|
||||||
@@ -788,11 +841,11 @@ class Plugin(BalPlugin):
|
|||||||
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
" {willexecutor}: replaced with the will-executor URL of the item\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
)
|
||||||
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
|
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
|
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
|
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
|
||||||
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
|
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
|
# NOTE: the ADVANCED-only widgets above have ALREADY been given their
|
||||||
# correct initial visibility inline (via _hide_if_basic) BEFORE being
|
# correct initial visibility inline (via _hide_if_basic) BEFORE being
|
||||||
@@ -801,15 +854,57 @@ class Plugin(BalPlugin):
|
|||||||
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
||||||
# setVisible() loop here.
|
# setVisible() loop here.
|
||||||
|
|
||||||
grid.addWidget(heir_repush, 14, 0)
|
grid.addWidget(heir_repush, 13, 0)
|
||||||
grid.addWidget(
|
grid.addWidget(
|
||||||
HelpButton(
|
HelpButton(
|
||||||
"Broadcast all transactions to willexecutors including those already pushed"
|
"Broadcast all transactions to willexecutors including those already pushed"
|
||||||
),
|
),
|
||||||
14,
|
13,
|
||||||
2,
|
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)
|
||||||
|
|
||||||
# ----------------------------------------------------------------- #
|
# ----------------------------------------------------------------- #
|
||||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||||
# their factory defaults. It only resets the settings exposed by THIS #
|
# their factory defaults. It only resets the settings exposed by THIS #
|
||||||
@@ -834,7 +929,6 @@ class Plugin(BalPlugin):
|
|||||||
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
||||||
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
(self.EDITABLE_DATES, heir_editable_dates, "check"),
|
||||||
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
|
(self.NUM_REMINDERS, heir_num_reminders, "spin"),
|
||||||
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
|
|
||||||
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
|
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
|
||||||
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
(self.EVENT_SUMMARY, edit_event_summary, "line"),
|
||||||
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
|
||||||
@@ -842,6 +936,8 @@ class Plugin(BalPlugin):
|
|||||||
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
(self.CALENDAR_APP, edit_calendar_app, "line"),
|
||||||
(self.SAVE_HISTORY, heir_save_history, "check"),
|
(self.SAVE_HISTORY, heir_save_history, "check"),
|
||||||
(self.HISTORY_LABEL, edit_history_label, "line"),
|
(self.HISTORY_LABEL, edit_history_label, "line"),
|
||||||
|
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||||
|
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||||
]
|
]
|
||||||
for cfg, widget, kind in resets:
|
for cfg, widget, kind in resets:
|
||||||
# Persist the default value back into the Electrum config.
|
# Persist the default value back into the Electrum config.
|
||||||
|
|||||||
@@ -28,8 +28,53 @@ from ...core.input_rules import (
|
|||||||
)
|
)
|
||||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
from ...core.reminders import build_ics_reminders, write_temp_ics
|
||||||
from .calendar import BalCalendar, BalCalendarButton
|
from .calendar import BalCalendar, BalCalendarButton
|
||||||
from .common import *
|
from .common import (
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
_,
|
||||||
|
_logger,
|
||||||
|
Any,
|
||||||
|
BTCAmountEdit,
|
||||||
|
BalTimestamp,
|
||||||
|
ColorScheme,
|
||||||
|
DECIMAL_POINT,
|
||||||
|
Decimal,
|
||||||
|
HelpButton,
|
||||||
|
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||||
|
NLOCKTIME_MAX,
|
||||||
|
Optional,
|
||||||
|
QAbstractSpinBox,
|
||||||
|
QCheckBox,
|
||||||
|
QColor,
|
||||||
|
QComboBox,
|
||||||
|
QDateTime,
|
||||||
|
QDateTimeEdit,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPainter,
|
||||||
|
QPalette,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStyle,
|
||||||
|
QStyleOptionFrame,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
Qt,
|
||||||
|
Union,
|
||||||
|
Util,
|
||||||
|
Will,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
datetime,
|
||||||
|
getSaveFileName,
|
||||||
|
log_error,
|
||||||
|
os,
|
||||||
|
partial,
|
||||||
|
pyqtSignal,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
signature_suffix,
|
||||||
|
status_color,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .window import BalWindow
|
from .window import BalWindow
|
||||||
|
|||||||
@@ -24,8 +24,63 @@ from ...core.checkalive import (
|
|||||||
check_alive_expired,
|
check_alive_expired,
|
||||||
resolve_date_to_check,
|
resolve_date_to_check,
|
||||||
)
|
)
|
||||||
from .common import *
|
from .common import (
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
_,
|
||||||
|
_logger,
|
||||||
|
AmountException,
|
||||||
|
BalPlugin,
|
||||||
|
Buttons,
|
||||||
|
CancelButton,
|
||||||
|
ElectrumWindow,
|
||||||
|
FileImportFailed,
|
||||||
|
HeirChangeException,
|
||||||
|
HeirNotFoundException,
|
||||||
|
Heirs,
|
||||||
|
HelpButton,
|
||||||
|
Mapping,
|
||||||
|
Network,
|
||||||
|
NoHeirsException,
|
||||||
|
NoWillExecutorNotPresent,
|
||||||
|
NotCompleteWillException,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
|
OkButton,
|
||||||
|
PaymentIdentifier,
|
||||||
|
QGridLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QTimer,
|
||||||
|
QVBoxLayout,
|
||||||
|
SerializationError,
|
||||||
|
Transaction,
|
||||||
|
TxDialog,
|
||||||
|
TxFeesChangedException,
|
||||||
|
Util,
|
||||||
|
Will,
|
||||||
|
WillExecutorFeeTooHighException,
|
||||||
|
WillExecutorNotPresent,
|
||||||
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
|
WillPostponedException,
|
||||||
|
WillexecutorChangeException,
|
||||||
|
Willexecutors,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
copy,
|
||||||
|
export_meta_gui,
|
||||||
|
import_meta_gui,
|
||||||
|
is_onion_url,
|
||||||
|
is_op_return_address,
|
||||||
|
is_tor_active,
|
||||||
|
log_error,
|
||||||
|
partial,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
read_json_file,
|
||||||
|
show_on_top,
|
||||||
|
shown_cv,
|
||||||
|
time,
|
||||||
|
tx_from_any,
|
||||||
|
write_json_file,
|
||||||
|
)
|
||||||
from .dialogs import (
|
from .dialogs import (
|
||||||
BalBuildWillDialog,
|
BalBuildWillDialog,
|
||||||
BalDialog,
|
BalDialog,
|
||||||
@@ -39,6 +94,13 @@ from .widgets import LockTimeWidget, PercAmountEdit
|
|||||||
|
|
||||||
|
|
||||||
class BalWindow:
|
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"):
|
def __init__(self, bal_plugin: "BalPlugin", window: "ElectrumWindow"):
|
||||||
self.bal_plugin = bal_plugin
|
self.bal_plugin = bal_plugin
|
||||||
self.window = window
|
self.window = window
|
||||||
@@ -56,6 +118,9 @@ class BalWindow:
|
|||||||
# ``init_menubar_tools`` twice would add the Heirs/Will tabs and the
|
# ``init_menubar_tools`` twice would add the Heirs/Will tabs and the
|
||||||
# menu actions twice, producing the garbled/condensed menu entry.
|
# menu actions twice, producing the garbled/condensed menu entry.
|
||||||
self._menubar_initialized = False
|
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
|
self.bal_plugin.get_decimal_point = self.window.get_decimal_point
|
||||||
|
|
||||||
if self.window.wallet:
|
if self.window.wallet:
|
||||||
@@ -1034,6 +1099,297 @@ class BalWindow:
|
|||||||
password = self.get_wallet_password(message)
|
password = self.get_wallet_password(message)
|
||||||
return password
|
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):
|
def on_close(self):
|
||||||
# Wallet is closing: run the closing "build will" task and tear down
|
# 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
|
# the plugin's tabs/menu. Each step is isolated so that one failure
|
||||||
@@ -1044,7 +1400,10 @@ class BalWindow:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 1) Business logic: build/save the will on close (unchanged behaviour).
|
# 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:
|
try:
|
||||||
|
if self.bal_plugin.REBUILD_ON_CLOSE.get():
|
||||||
close_window = BalBuildWillDialog(self)
|
close_window = BalBuildWillDialog(self)
|
||||||
close_window.build_will_task()
|
close_window.build_will_task()
|
||||||
self.save_willitems()
|
self.save_willitems()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "bal",
|
"name": "bal",
|
||||||
"fullname": "Bitcoin After Life",
|
"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.",
|
"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",
|
"author": "Svatantrya",
|
||||||
"licence": "MIT",
|
"licence": "MIT",
|
||||||
"available_for": [
|
"available_for": [
|
||||||
"qt"
|
"qt",
|
||||||
|
"cmdline"
|
||||||
],
|
],
|
||||||
"icon": "icons/bal32x32.png"
|
"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>
|
<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>
|
</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>
|
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
||||||
</div>
|
</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`.*
|
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
|
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
||||||
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
||||||
> ensure the inheritance is correctly executed.
|
> 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:
|
About installing a will‑executor server or collaboration, send your request to:
|
||||||
**info@bitcoin-after.life**
|
**info@bitcoin-after.life**
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ select =["E", "W", "F", "I", "N", "B"]
|
|||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[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 explicit imports
|
||||||
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
|
|
||||||
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
"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/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
|
||||||
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
|
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
|
||||||
|
|||||||
10616
tests/karen7
10616
tests/karen7
File diff suppressed because one or more lines are too long
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()
|
||||||
@@ -61,14 +61,14 @@ def test_advanced_mode_uses_threshold_absolute():
|
|||||||
def test_advanced_mode_parses_relative_threshold():
|
def test_advanced_mode_parses_relative_threshold():
|
||||||
# A relative threshold means "N days BEFORE the delivery": it resolves
|
# A relative threshold means "N days BEFORE the delivery": it resolves
|
||||||
# against the stored locktime (backwards), not forward from now.
|
# 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
|
fake_now = 1_800_000_000.0
|
||||||
locktime = fake_now + 90 * 86400
|
locktime = fake_now + 90 * 86400
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
settings = {"threshold": "30d", "locktime": locktime}
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
# 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)
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
- timedelta(days=30)).timestamp()
|
- timedelta(days=30)).timestamp()
|
||||||
assert abs(result - expected) < 1
|
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():
|
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
||||||
"""A relative locktime is resolved against 'now' first, then the relative
|
"""A relative locktime is resolved against 'now' first, then the relative
|
||||||
threshold counts N days back from it (matches the settings widget)."""
|
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
|
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)
|
result = resolve_date_to_check(False, settings, now=fake_now)
|
||||||
# Recompute the expected value with the same resolution rules:
|
# Recompute the expected value with the same resolution rules:
|
||||||
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
# 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()
|
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||||
assert abs(result - expected) < 1
|
assert abs(result - expected) < 1
|
||||||
assert result > fake_now
|
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
|
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
||||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
delivery date (built_locktime), not to "now": an unchanged will must not
|
||||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
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"}
|
settings = {"threshold": "30d", "locktime": "2y"}
|
||||||
# On build day the frozen delivery is authoritative: date_to_check is
|
# On build day the frozen delivery is authoritative: date_to_check is
|
||||||
# frozen - 30d and NEVER drifts, however much later the clock gets.
|
# 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():
|
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
||||||
"""Without a built will there is no anchor: keeps the legacy now-based
|
"""Without a built will there is no anchor: keeps the legacy now-based
|
||||||
resolution (a moving target, used only before the first build)."""
|
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
|
from bal.core.plugin_base import BalTimestamp
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
fake_now = 1_800_000_000.0
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
settings = {"threshold": "30d", "locktime": "90d"}
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
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()
|
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
||||||
assert abs(result - expected) < 1
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def test_relative_days():
|
|||||||
|
|
||||||
def test_resolve_locktime_against_tx_absolute():
|
def test_resolve_locktime_against_tx_absolute():
|
||||||
"""An absolute current date is returned unchanged (compared vs the tx)."""
|
"""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(str(frozen), "1y", frozen) == frozen
|
||||||
assert Util.resolve_locktime_against_tx(frozen, str(frozen), 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():
|
def test_resolve_locktime_against_tx_unchanged_relative():
|
||||||
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
|
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
|
||||||
(coherent), instead of drifting one day per day away from it."""
|
(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)
|
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
|
||||||
assert resolved == frozen
|
assert resolved == frozen
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ def test_resolve_locktime_against_tx_unchanged_relative():
|
|||||||
def test_resolve_locktime_against_tx_lengthened():
|
def test_resolve_locktime_against_tx_lengthened():
|
||||||
"""A lengthened relative recipe resolves later than the frozen tx locktime
|
"""A lengthened relative recipe resolves later than the frozen tx locktime
|
||||||
(this is what the postpone check uses to trigger invalidation)."""
|
(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)
|
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
|
||||||
assert resolved == frozen + 365 * 86400
|
assert resolved == frozen + 365 * 86400
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ def test_resolve_locktime_against_tx_lengthened():
|
|||||||
def test_resolve_locktime_against_tx_shortened():
|
def test_resolve_locktime_against_tx_shortened():
|
||||||
"""A shortened relative recipe resolves earlier than the frozen tx locktime
|
"""A shortened relative recipe resolves earlier than the frozen tx locktime
|
||||||
(this is what the anticipate/rebuild path uses)."""
|
(this is what the anticipate/rebuild path uses)."""
|
||||||
frozen = 1817438400
|
frozen = 1817424000
|
||||||
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
|
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
|
||||||
assert resolved < frozen
|
assert resolved < frozen
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ def test_resolve_locktime_against_tx_shortened():
|
|||||||
def test_resolve_locktime_against_tx_no_relative_anchor():
|
def test_resolve_locktime_against_tx_no_relative_anchor():
|
||||||
"""When the built recipe was absolute there is no anchor: falls back to the
|
"""When the built recipe was absolute there is no anchor: falls back to the
|
||||||
legacy forward-from-now resolution (returns a timestamp, no crash)."""
|
legacy forward-from-now resolution (returns a timestamp, no crash)."""
|
||||||
frozen = 1817438400
|
frozen = 1817424000
|
||||||
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
|
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
|
||||||
assert isinstance(result, int)
|
assert isinstance(result, int)
|
||||||
assert result > 1700000000
|
assert result > 1700000000
|
||||||
@@ -319,27 +319,6 @@ def test_anticipate_locktime():
|
|||||||
assert low >= 1
|
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():
|
def test_get_lowest_locktimes():
|
||||||
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
||||||
# 500000, 900000 are block-height (< THRESHOLD)
|
# 500000, 900000 are block-height (< THRESHOLD)
|
||||||
@@ -351,18 +330,6 @@ def test_get_lowest_locktimes():
|
|||||||
assert Util.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():
|
def test_utxo_to_str():
|
||||||
class FakeUtxo:
|
class FakeUtxo:
|
||||||
def to_str(self): return "txid:0"
|
def to_str(self): return "txid:0"
|
||||||
@@ -518,10 +485,7 @@ if __name__ == "__main__":
|
|||||||
test_get_value_amount()
|
test_get_value_amount()
|
||||||
test_chk_locktime()
|
test_chk_locktime()
|
||||||
test_anticipate_locktime()
|
test_anticipate_locktime()
|
||||||
test_cmp_locktime()
|
|
||||||
test_get_locktimes()
|
|
||||||
test_get_lowest_locktimes()
|
test_get_lowest_locktimes()
|
||||||
test_get_will_spent_utxos()
|
|
||||||
test_utxo_to_str()
|
test_utxo_to_str()
|
||||||
test_cmp_utxo()
|
test_cmp_utxo()
|
||||||
test_in_utxo()
|
test_in_utxo()
|
||||||
|
|||||||
@@ -126,22 +126,6 @@ def test_willitem_str_repr():
|
|||||||
# Will static methods
|
# 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():
|
def test_will_only_valid():
|
||||||
item1 = _make_willitem_blank()
|
item1 = _make_willitem_blank()
|
||||||
item2 = _make_willitem_blank()
|
item2 = _make_willitem_blank()
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
|
|||||||
})
|
})
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||||
# Set the input value so the balance calculation works.
|
# 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:
|
if not valid:
|
||||||
item.set_status("INVALIDATED", True)
|
item.set_status("INVALIDATED", True)
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -281,10 +281,10 @@ class TestKaren7BuildAndInvalidate:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_built_tx_has_karen7_heirs(self):
|
def test_built_tx_has_karen7_heirs(self):
|
||||||
"""The built will contains karen7's four heirs."""
|
"""The built will contains karen7's six heirs."""
|
||||||
assert len(self.heirs_model) == 4
|
assert len(self.heirs_model) == 6
|
||||||
assert list(self.heirs_model.keys()) == [
|
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):
|
def test_will_items_are_valid(self):
|
||||||
|
|||||||
@@ -570,8 +570,8 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
|||||||
h = Heirs.__new__(Heirs)
|
h = Heirs.__new__(Heirs)
|
||||||
h.update(heirs_data)
|
h.update(heirs_data)
|
||||||
|
|
||||||
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
|
assert len(h) == 6, f"expected 6 heirs, got {len(h)}"
|
||||||
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
|
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.
|
# Mock the Electrum-heavy parts so the build can run in a test context.
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
|
|||||||
@@ -269,7 +269,9 @@ def test_prepare_will_builds_and_persists():
|
|||||||
|
|
||||||
assert item.get_status("VALID"), "fresh items default to VALID"
|
assert item.get_status("VALID"), "fresh items default to VALID"
|
||||||
assert txid == item.tx.txid()
|
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 not item.tx.is_complete(), "unsigned will must not be complete"
|
||||||
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
|
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ _VALID_TX_HEX = (
|
|||||||
"42146f11ef8414ae929feaafc388ac00000000"
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
|
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05 00:00 UTC,
|
||||||
# will built 2026-08-05 with a "1y" recipe.
|
# i.e. a will built 2026-08-05 with a "1y" recipe.
|
||||||
_FROZEN = 1817438400
|
_FROZEN = 1817424000
|
||||||
|
|
||||||
|
|
||||||
def _make_will_item(heirs, tx_locktime, status_complete=False):
|
def _make_will_item(heirs, tx_locktime, status_complete=False):
|
||||||
@@ -173,15 +173,15 @@ def test_karen7_frozen_delivery_not_expired():
|
|||||||
window opens BEFORE the delivery, so the will is never read as expired."""
|
window opens BEFORE the delivery, so the will is never read as expired."""
|
||||||
data = _load_karen7()
|
data = _load_karen7()
|
||||||
will_settings = data["will_settings"]
|
will_settings = data["will_settings"]
|
||||||
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||||
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
||||||
assert built_locktime == _FROZEN
|
assert built_locktime == int(wi.tx.locktime)
|
||||||
|
|
||||||
date_to_check = resolve_date_to_check(
|
date_to_check = resolve_date_to_check(
|
||||||
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
||||||
)
|
)
|
||||||
assert int(date_to_check) < _FROZEN
|
assert int(date_to_check) < built_locktime
|
||||||
# Re-evaluated 10 days later the window is identical (no daily drift).
|
# Re-evaluated 10 days later the window is identical (no daily drift).
|
||||||
later = resolve_date_to_check(
|
later = resolve_date_to_check(
|
||||||
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
|
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
|
||||||
@@ -191,24 +191,27 @@ def test_karen7_frozen_delivery_not_expired():
|
|||||||
|
|
||||||
|
|
||||||
def test_karen7_unchanged_heirs_are_coherent():
|
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."""
|
signed tx: the plugin must NOT ask to invalidate the will."""
|
||||||
data = _load_karen7()
|
data = _load_karen7()
|
||||||
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
||||||
|
# Use _FROZEN (a UTC-midnight value) so the check is compatible with
|
||||||
|
# the UTC anchoring code.
|
||||||
|
frozen_locktime = _FROZEN
|
||||||
date_to_check = resolve_date_to_check(
|
date_to_check = resolve_date_to_check(
|
||||||
False, data["will_settings"],
|
False, data["will_settings"],
|
||||||
now=1_800_000_000.0,
|
now=1_800_000_000.0,
|
||||||
built_locktime=int(wi.tx.locktime),
|
built_locktime=frozen_locktime,
|
||||||
)
|
)
|
||||||
outcome = _run_heir_check(
|
outcome = _run_heir_check(
|
||||||
data["will"][valid_wid]["heirs"],
|
data["will"][valid_wid]["heirs"],
|
||||||
data["heirs"],
|
data["heirs"],
|
||||||
int(wi.tx.locktime),
|
frozen_locktime,
|
||||||
status_complete=True,
|
status_complete=True,
|
||||||
)
|
)
|
||||||
assert outcome.startswith("coherent"), outcome
|
assert outcome.startswith("coherent"), outcome
|
||||||
assert int(date_to_check) < int(wi.tx.locktime)
|
assert int(date_to_check) < frozen_locktime
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ class TestNoWillexecutorKaren7:
|
|||||||
heirs_data = _KAREN7_DATA["heirs"]
|
heirs_data = _KAREN7_DATA["heirs"]
|
||||||
h = Heirs.__new__(Heirs)
|
h = Heirs.__new__(Heirs)
|
||||||
h.update(heirs_data)
|
h.update(heirs_data)
|
||||||
assert len(h) == 4
|
assert len(h) == 6
|
||||||
|
|
||||||
self.heirs_obj = h
|
self.heirs_obj = h
|
||||||
self.bal_plugin = _Karen7BalPlugin()
|
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")
|
||||||
@@ -170,7 +170,7 @@ def test_simulate_task_phase1():
|
|||||||
heirs_data = KAREN7_DATA["heirs"]
|
heirs_data = KAREN7_DATA["heirs"]
|
||||||
h = Heirs.__new__(Heirs)
|
h = Heirs.__new__(Heirs)
|
||||||
h.update(heirs_data)
|
h.update(heirs_data)
|
||||||
assert len(h) == 4
|
assert len(h) == 6
|
||||||
|
|
||||||
# 2. Build UTXOs
|
# 2. Build UTXOs
|
||||||
utxos = build_utxos(KAREN7_DATA)
|
utxos = build_utxos(KAREN7_DATA)
|
||||||
|
|||||||
Reference in New Issue
Block a user