9 Commits

Author SHA1 Message Date
9c4697c923 Merge pull request 'core+gui: name the real cause of a failed build instead of guessing' (#7) from ui/build-will-error-messages into main
Reviewed-on: #7
2026-09-09 12:31:28 +00:00
42f05d3c4f core+gui: name the real cause of a failed build instead of guessing
The Building Will report showed a fixed list of three "possible reasons"
whenever a build produced nothing, regardless of what actually happened; in a
case reproduced from the owner log all three were false and the real cause was
not even listed. The "Checking your will" row had the same flaw, showing one
sentence ("Found CHANGES to the DATE or the HEIRS") for five situations,
including one where it is plainly wrong (funds received).

core/heirs.py: record WHY buildTransactions gave up in a new last_build_error
attribute (8 reason codes), set at each path that previously returned empty
with no explanation, plus a processed_willexecutors counter to tell "every
will-executor was skipped" apart from "we tried and failed". Also fix a latent
crash in the prepare_transactions handler, which read a no-longer-existing
e.heirname attribute and re-raised the resulting AttributeError, masking the
real error.

gui/qt/dialogs.py: add msg_alert() (amber warning sign, body text in the theme
colour, readable in both themes), _build_failure_message() and
_check_failure_message() to turn those causes into one precise sentence each,
with an honest "cause could not be determined" fallback. Catch
BalanceTooLowException, which already carried the figures but fell through to
the generic red technical error.

No new exception classes were introduced (owner request): the plain
NotCompleteWillException cases are told apart structurally, not by text.
2026-09-04 11:52:21 +02:00
3a9ee5adb9 core+gui: timezone-correct datetimes, deep-copy WillItem, dead code removal, explicit imports 2026-08-19 20:57:22 -04:00
2c9f6bdd9d gui: remove 'Add transaction without willexecutor' from plugin settings dialog
The checkbox is already available in the Will-Executor tab; showing it
in the settings dialog was redundant. Renumber grid rows 5-16 -> 4-15
to close the gap.
2026-08-17 23:42:01 -04:00
4dfb3fc41c fix: chainname regtest bug (classproperty); add no-heirs buttons in build-will dialog
- bal/core/plugin_base.py: change chainname from frozen class attribute
  to @classproperty so it reads constants.net.NET_NAME at runtime, fixing
  regtest/testnet always downloading the mainnet executor list
- bal/core/willexecutors.py: remove module-level chainname capture;
  all uses now read BalPlugin.chainname directly
- bal/gui/qt/dialogs.py: when BalBuildWillDialog detects no heirs,
  return 'no_heirs' signal and show Heirs/Wizard/Close buttons (mirroring
  the existing no-willexecutor pattern); add HeirsDialog with full
  HeirListWidget (New Heir, Import, Export)
2026-08-17 12:06:21 -04:00
1ae1617172 cli: add bal_will_autorebuild (headless one-shot check/rebuild/sign/push flow with invalidation tx) 2026-08-16 06:40:40 -04:00
125bf09b9a gui: rebuild the will automatically on new transactions (AUTO_REBUILD, default off; anticipate delivery by 1 day, invalidate on-chain only when the anticipated locktime crosses the check-alive threshold) 2026-08-16 06:40:35 -04:00
b5752a42f0 cli: add headless command-line layer (bal_* commands for the daemon, cmdline entry point, manifest 'cmdline' support, offline controller tests) 2026-08-14 23:57:28 -04:00
ce659048ca core: add 'Rebuild will on wallet close' setting to skip the build wizard at close (REBUILD_ON_CLOSE); settings checkbox + tests 2026-08-14 23:56:14 -04:00
46 changed files with 6179 additions and 10292 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

21
bal/cli/plugin.py Normal file
View 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
View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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='{}'>&#9888;</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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — reBroadcast
<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>

View File

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

View File

@@ -475,6 +475,27 @@ transactions can have in the WILL tab, on each willexecutor 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 willexecutor server or collaboration, send your request to: About installing a willexecutor server or collaboration, send your request to:
**info@bitcoin-after.life** **info@bitcoin-after.life**

View File

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

File diff suppressed because one or more lines are too long

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

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

View 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")

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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")

View File

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