24 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
c80f0e1524 test: end-to-end GUI tests for will flows (prepare, sign->COMPLETE, export/merge roundtrip, insufficient funds) 2026-08-14 23:41:39 -04:00
e8e4d5e451 core: anchor relative locktime/threshold recipes to the built tx locktime so an unchanged will no longer reads as expired/postponed; stop the daily invalidate prompt (karen7) 2026-08-09 12:08:25 -04:00
e74c348bbb core: extract GUI-free logic into bal.core (reminders/checkalive/input_rules); fix save/sign tx RLock pickle by serializing tx; keep invalid heirs in wallet on build; tb1 testnet will-executor addresses; move tests to core modules 2026-08-05 17:17:32 -04:00
fdcab7c6bd core: replace debug print() with _logger, drop dead print_transaction helper 2026-08-01 23:33:52 -04:00
eac03da530 docs: align all markdown docs with current code (v0.6.1)
- CHANGELOG: add entries #47 (is_selected/is_valid fee bounds, extremes allowed) and #48 (merge_will crash fix on missing date_to_check)
- HANDOFF: Electrum 4.7.2 and 4.8.0, current layout (qt.py, plugin.py, wallet_util/, docs/, bal_cli.py, 36-file ZIP), runtime/lint venv paths, standalone-script tests (427 collected), direct-to-main workflow, version history up to v0.6.1
- README: drop VERSION from layout, correct anticipate/expire/postpone behavior, update test commands
- docs/manual/README.md: fix Tools menu label to Will-Executors, add BASIC/ADVANCED notes, extend chromatic status table (Partially signed, Updated)
- docs/inheritance-options.md: add PARTIALLY_SIGNED status/transition/colour, version note to v0.6.1
- docs/README.md: make HTML-hosting instructions host-agnostic
- bal/README.md: Prepare button lives on the WILL tab
2026-08-01 23:08:07 -04:00
167d1228b2 willexecutors: fee-bound is_valid (extremes allowed), is_selected flag-only; fix merge_will crash on missing date_to_check; DNSSEC async, ical folding, pyright/ruff cleanup; refresh karen7/samanta7 fixtures 2026-08-01 22:08:53 -04:00
8649556a18 Will toolbar: Export dropdown submenu (All / Valid / Valid NC)
Replace the separate Export / Export V-NC toolbar actions with a cascading
Export submenu: All exports every will item, Valid exports only valid ones,
Valid NC exports valid items that are not yet fully signed.
2026-08-01 19:31:28 -04:00
5e53a87bb6 Will list: context menu actions and will-executor bulk tools
PreviewList: right-click menu with sign / details / broadcast / check /
copy id / copy / merge from txn / delete, with multi-selection support and
availability rules (broadcast force-repushes, delete restricted to invalid
or unsigned txs, sign skips non-valid txs).

WillExecutorWidget: Export, Ping All and Select All become dropdown buttons
(export all/selected/valid, ping all/selected, select all/only valid,
deselect all/only invalid).

WillExecutorListWidget: single-cell Copy action.

BalWindow: txids-scoped sign/broadcast plumbing and merge_single_transaction.
2026-08-01 19:11:35 -04:00
251fd93cbd BalWindow/plugin: history persistence, will import/merge, sig tracking, local-spender fixes 2026-08-01 17:21:36 -04:00
bdeef3dc6a lint: ruff cleanup pass across bal/ and tests/
- Sort imports and fix pyproject ruff config (per-file ignores for
  intentional Qt/core exceptions)
- Mark Heirs.validate_* helpers as @staticmethod
- Clean up dead code, rename shadowing vars, use raise ... from
- Add AGENTS.md with env/lint/test/release guidance
2026-07-31 16:03:17 -04:00
6f7a364bc6 Add OP_RETURN support for heirs
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 amount
calculations. GUI dialog shows a message field with the decoded text,
the heir list displays the decoded message, and the OP_RETURN output
is emitted with zero value in inheritance transactions.
2026-07-30 22:06:49 -04:00
a1b13947c3 fix version lookup for internal plugin install; silence JSON parse debug noise 2026-07-30 18:53:43 -04:00
6e0ae7e4ed add samanta7 for tests 2026-07-30 18:25:52 -04:00
9275e9c6a2 Add Willexecutors.is_valid + grey italic styling for invalid executors 2026-07-30 18:11:29 -04:00
658e3dca0b add make-release.sh, svatantrya.asc, and update HANDOFF.md
- make-release.sh: automated release script (tests, lint, build, GPG sign, SHA-256, Gitea)
- svatantrya.asc: PGP public key for release verification
- HANDOFF.md: updated release workflow documentation
- willexecutors.py: input validation for addresses, fees, and API responses
- manifest.json: fixed version back to 0.6.1
- test_core_plugin_base.py: updated baltx_fees default
2026-07-22 21:07:32 -04:00
88 changed files with 15828 additions and 11749 deletions

29
.gitignore vendored
View File

@@ -4,3 +4,32 @@
bal-electrum-plugin.zip bal-electrum-plugin.zip
electrum-src/ electrum-src/
preview_*.png preview_*.png
.env
# Virtual environment
venv/
.venv/
# Node modules
node_modules/
# Editor temp files
*.swp
*.swo
*.bak
# Local tooling / scratch files (not part of the plugin)
TODO_LIST.md
opencode.json
package.json
package-lock.json
pyrightconfig.json
# Debug / scratch files
debug.py
init.ol
temp*
tmp*
# Release artifacts
bal_v*.zip.*

94
AGENTS.md Normal file
View File

@@ -0,0 +1,94 @@
# AGENTS.md
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
## Environments (critical)
Two separate venvs; using the wrong one is the #1 mistake.
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source "$BAL_HOME/electrum/env/bin/activate"`
This is an editable install of the Electrum 4.8.0 checkout at
`$BAL_HOME/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests.
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
The plugin's `bal/` directory is symlinked into
`electrum/electrum/plugins/bal` (internal-plugin install used during dev).
## Test & verify
Tests work **both** as standalone scripts and via pytest (tests use `def test_*`
naming and also have `if __name__ == "__main__"` blocks). Run a single file
directly:
```bash
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_core_heirs.py # core, no Qt needed
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
```
Or run a batch with pytest (as `make-release.sh` does):
```bash
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
```
- Most core tests run offline (no wallet/network). Some files
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
exercise will-executor/network flows and need the live servers — don't rely on
them for quick verification.
- `tests/smoke_test.py` proves clean import under real Electrum:
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
- `tests/external_zip_test.py` loads the built zip the way Electrum's plugin
dialog does (`electrum_external_plugins.bal`); run it after `build_zip.py`.
## Lint / typecheck
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
E501 ignored). Per-file ignores suppress `F403`/`F405` for the intentional
`from .common import *` hub pattern in `bal/gui/qt/`.
- Lint via the repo venv: `./venv/bin/ruff`
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
dynamically-attached attrs (e.g. `self.window`, `BalPlugin.*`); don't chase
them.
## Architecture
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
`plugin_base.py`, `util.py`, `checkalive.py`, `reminders.py`,
`input_rules.py`).
Must never import Qt.
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
`plugin.py` is the Electrum `@hooks` entry. `qt.py` is a zipimport shim.
`common.py` uses `import *` intentionally (ruff suppresses F403/F405 here);
`bal/gui/qt/*.py` all import from it.
- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon
entry point, `commands.py` registers `bal_*` commands with Electrum.
- `bal/wallet_util/` = wallet helper utilities for Qt and core.
- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers
via `manifest.json`; they import the real `Plugin` class via `importlib`.
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
`make-release.sh`).
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
registration API differs between them (`json_db.register_dict` vs
`stored_dict.register_name`).
## Build / release
```bash
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256)
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
```
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth.
- Remote is Gitea (`origin` = bitcoin-after.life). `.env` holds a Gitea token
(gitignored, never commit it).

View File

@@ -2510,3 +2510,486 @@ pre-commit hook. The version must be read at runtime from `manifest.json` only.
`bitcoinafterlife-patch-5`). No functional change to inheritance behavior. `bitcoinafterlife-patch-5`). No functional change to inheritance behavior.
**Outcome:** DONE. **Outcome:** DONE.
---
## 47. Willexecutors: fee-bound `is_valid` (extremes allowed), `is_selected` flag-only
**Date:** 2026-08-01
**Goal:** separate the "is this executor selected" question from the "is its fee
acceptable" question, and let the fee bounds be inclusive at both extremes so an
executor whose fee is *exactly* dust or *exactly* the max fee is valid.
**What changed (`bal/core/willexecutors.py`):**
- `is_selected()` no longer takes a `max_fee` parameter - it only reads/sets the
`"selected"` flag. The previous max-fee cut-off inside `is_selected` rejected
executors with `base_fee >= max_fee` even when they were otherwise valid.
- `is_valid()` bounds changed from strict (`<=`) to inclusive (`<` / `>`):
- `base_fee < dust` is invalid (was `<= dust`), so a fee exactly equal to
dust is now valid.
- `base_fee > max_fee` is invalid (was `>= max_fee`), so a fee exactly equal
to the max fee is now valid.
- Defensive `int(base_fee or 0)` in the fee-range import validation so a missing
or empty `base_fee` no longer raises.
**Verification:**
- New tests in `tests/test_no_willexecutor_karen7.py` (class
`TestNoWillexecutorKaren7`): `is_selected` only reads/sets the flag (setter,
default-False); `is_valid` accepts fee == max and fee == dust, rejects
fee > max and fee < dust, and requires a valid address.
- Existing `build_will` test confirms a fee above max still raises.
**Outcome:** DONE.
---
## 48. Fix `merge_will` crash on missing `date_to_check`
**Date:** 2026-08-01
**Goal:** allow *Will → Merge → Import file* to work as the very first action
of a session, without requiring the normal wizard flow to have run first.
**What changed (`bal/gui/qt/window.py`):**
- `merge_will()` crashed with `AttributeError` when `self.date_to_check` was not
yet set (merge-from-file can run before `init_class_variables()`). It now
falls back to `datetime.now().timestamp()` when the attribute is missing or
`None`, so the local validity check and the trailing `update_all()` always
have a reference timestamp.
- Several `log_error(exec_info, self.bal_window)` calls inside `BalWindow`
methods (broadcast failure, will-building failure) pointed at a wrong object
(`self.bal_window` does not exist on a `BalWindow`); corrected to
`log_error(exec_info, self)` so the error-reporting path itself cannot fail.
**Verification:**
- New tests in `tests/test_import_will_details.py`:
`test_merge_will_missing_date_to_check_defaults_to_now` and
`test_merge_will_validity_error_logs_without_crashing`.
**Outcome:** DONE.
---
## 49. OP_RETURN support for heirs
**Date:** 2026-07-30
**Goal:** allow heirs to produce an OP_RETURN output instead of a regular BTC
payment. An address prefixed with `OP_RETURN:` carries hex data (max 80 bytes);
such heirs always have amount 0 and are excluded from the normal amount
calculations (percentage normalization, dust checks, leftover redistribution).
**What changed:**
- `bal/core/heirs.py`
- `validate_heir`: new `OP_RETURN:<hex>` address validation (rejects non-hex,
> 80 bytes). OP_RETURN heirs are stored with amount `"0"` and carry the raw
hex data in the address field.
- `prepare_lists`: OP_RETURN heirs are skipped during amount calculation
(`normalize_perc`, dust checks). They are kept in the will but produce a
zero-value output.
- `buildTransactions`: when building the transaction, OP_RETURN heirs emit a
`OP_RETURN <hex>` scriptPubKey output with value 0, matching Bitcoin's
OP_RETURN output standard.
- `get_transactions`: OP_RETURN heirs are grouped with their locktime peers
but excluded from fee/dust arithmetic.
- `bal/gui/qt/window.py`
- Heir dialog / wizard: the address field accepts `OP_RETURN:` prefix and
shows a decoded-text message field when an OP_RETURN address is entered.
- `build_will` / `_build_success_report`: OP_RETURN heirs display the
decoded message text in the build report instead of a BTC address.
- `bal/gui/qt/lists.py`
- Heir list: OP_RETURN heirs display the decoded message in the address
column and show "0" for the amount.
- `bal/gui/qt/common.py`
- Re-export the new `validate_op_return_hex` helper for the GUI layer.
- `tests/test_core_heirs.py`
- 8 new tests: OP_RETURN validation (valid hex, too long, non-hex), OP_RETURN
heirs excluded from amount calculations, OP_RETURN output shape in built
transactions, mixed OP_RETURN + regular heirs.
**Verification:**
- `ruff check` on changed production files: no new errors.
- Full test suite: 307 passed.
**Outcome:** DONE.
---
## 50. Core extraction: GUI-free logic into bal/core/ (reminders, checkalive, input_rules); RLock pickle fix
**Date:** 2026-08-05
**Goal:** extract GUI-free business logic that was previously embedded in Qt
widgets (`bal/gui/qt/widgets.py`) into standalone `bal/core/` modules, making
them independently testable without Qt. Also fix a critical `copy.deepcopy(tx)`
crash on Electrum 4.8 (`RLock` cannot be pickled) and improve wallet-DB
persistence robustness.
**What changed:**
- `bal/core/checkalive.py` (new)
- `resolve_date_to_check`: computes the effective check-alive timestamp from
will-settings and user type (BASIC uses `now()`; ADVANCED uses the stored
threshold). Extracted from `window.py init_class_variables`.
- `check_alive_expired`: pure-logic test for whether the check-alive has
passed. Previously duplicated inline in `window.py`.
- `bal/core/reminders.py` (new)
- `compute_reminder_offsets`, `basic_reminder_offsets`, `BALCalendar.write_ics`,
`BALCalendar._ics_provider`: all calendar/reminder logic extracted from
`widgets.py`. Generates iCal (.ics) files with separate VEVENT entries per
reminder date. No Qt dependency.
- `bal/core/input_rules.py` (new)
- `LockTimeRawEdit`, `LockTimeDateEdit`, `BalTimeEditWidget`,
`ThresholdTimeWidget`: GUI-free data models for locktime/threshold
validation and the Raw/Date selector logic. The Qt widgets in
`widgets.py` now thin-wrap these helpers.
- `bal/core/heirs.py`
- Fixed `copy.deepcopy(tx)` failure on Electrum 4.8: the `Transaction`
object contains a `_thread.RLock` that cannot be pickled. Will-item
persistence now re-parses the transaction from its hex serialization
instead of deep-copying.
- Invalid heirs (sentinel values from failed builds) are now kept in the
wallet DB instead of being silently dropped, so the user can see and
correct them.
- `bal/core/plugin_base.py`
- Minor adjustments to support the extracted modules.
- `bal/gui/qt/widgets.py`
- Major slim-down: business logic delegates to `bal/core/checkalive.py`,
`bal/core/reminders.py`, and `bal/core/input_rules.py`. Only Qt widget
creation and layout remain.
- `bal/gui/qt/calendar.py`
- Adapted to use `bal/core/reminders.py` for .ics generation.
- `bal/gui/qt/window.py`
- `init_class_variables` now calls `resolve_date_to_check` from
`bal/core/checkalive.py` instead of computing inline.
- `bal/gui/qt/common.py`
- Updated re-exports for the new core modules.
- Tests reorganized: core-only tests moved to `tests/test_core_checkalive.py`,
`tests/test_core_reminders.py`, `tests/test_core_input_rules.py` (run
without Qt).
- `tests/karen7`: fixture file compressed/updated for the new test structure.
**Verification:**
- `ruff check` on changed files: no new errors.
- Full test suite: 388 passed (significant increase due to new core test modules).
**Outcome:** DONE.
---
## 51. "Rebuild will on wallet close" setting (`REBUILD_ON_CLOSE`)
**Date:** 2026-08-14
**Goal:** add a new plugin setting that lets the plugin rebuild the will
automatically when Electrum closes, skipping the full Build wizard. When
enabled, closing Electrum triggers a one-shot prepare/inheritance flow
(check, rebuild if needed, sign, broadcast) without showing the
`BalBuildWillDialog`.
**What changed:**
- `bal/core/plugin_base.py`
- New persisted config `REBUILD_ON_CLOSE = BalConfig(config,
"bal_rebuild_on_close", False)` (default OFF), with explanatory comment.
- `bal/gui/qt/plugin.py`
- New "Rebuild on close" checkbox in the settings dialog, bound to
`REBUILD_ON_CLOSE`, with a tooltip explaining the behaviour. Added to the
"Reset to Default Setting" list.
- `bal/gui/qt/window.py`
- `on_close`: when `REBUILD_ON_CLOSE` is enabled, the close flow runs
the auto-rebuild path (check + rebuild + sign + push) instead of the
full wizard dialog. The legacy wizard-on-close path is kept when the
setting is OFF.
- `tests/test_rebuild_on_close_setting.py` (new)
- 8 tests: default OFF, toggle/persist, close triggers rebuild when ON,
close skips rebuild when OFF, reset restores default.
**Verification:**
- `ruff check` on changed files: no new errors.
- Full test suite: 396 passed.
**Outcome:** DONE.
---
## 52. Headless CLI layer (`bal/cli/`, `bal/cmdline.py`, `bal_*` daemon commands)
**Date:** 2026-08-14
**Goal:** expose the full BAL inheritance cycle via Electrum's command-line
interface (daemon mode), without the Qt GUI. This enables scripting,
automation, and headless server usage.
**What changed:**
- `bal/cmdline.py` (new)
- Zip-import shim for Electrum's `gui_name='cmdline'` plugin loader.
- Follows the same `importlib` pattern as `qt.py` but never imports Qt.
- Re-exports `Plugin` from `bal.cli.plugin`.
- `bal/cli/__init__.py` (new)
- Registers the `bal_*` commands with Electrum on import.
- `bal/cli/plugin.py` (new)
- `Plugin(BalPlugin)` -- minimal entry point for the daemon (no Qt hooks,
no `bal_windows`).
- `bal/cli/commands.py` (new)
- 30 `@plugin_command` async functions registered as `bal_*` commands:
settings (list/get/set/reset), heirs (list/show/add/update/delete/import/
export), will-executors (list/show/add/update/select/delete/ping/download/
import/export), will (status/check/prepare/sign/broadcast/export/
import_merge/invalidate/check_executor).
- Each command is a thin transport layer: validates args, delegates to
`BalController`, returns JSON-serializable results.
- `bal/cli/controller.py` (new, ~1100 lines)
- `BalController` -- headless replica of `BalWindow`. Reads/writes wallet DB,
config, and will-executors without any Qt dependency.
- Methods mirror `BalWindow` flows: `load_willitems`, `save_willitems`,
`init_class_variables`, `build_inheritance_transaction`, `sign_transactions`,
`push_transactions_to_willexecutors`, `check_transactions`, `export_json_file`,
`merge_will_from_file`, `invalidate_will`.
- Domain exceptions (`WillExpiredException`, `HeirNotFoundException`, etc.)
are converted to `UserFacingException` with clear text.
- `bal/manifest.json`
- `"available_for"` updated from `["qt"]` to `["qt", "cmdline"]`.
- `bal/__init__.py`
- Added `from .cli import commands` to register `bal_*` commands on import
(both CLI pre-parse and GUI startup).
- `tests/test_cli_commands_registered.py` (new)
- 4 tests: all `bal_*` commands registered, all are coroutines, no duplicate
registration, all args documented.
- `tests/test_cli_controller_offline.py` (new)
- Offline CRUD tests for heirs, will-executors, settings, and will
import/export merge via `BalController` (no network).
**Verification:**
- `ruff check` on new files: clean.
- Full test suite: 427 passed.
- `tests/test_cli_commands_registered.py`: all 4 tests pass.
**Outcome:** DONE.
---
## 53. Auto-rebuild on new transactions (`AUTO_REBUILD`)
**Date:** 2026-08-16
**Goal:** when new transactions are detected in the wallet (e.g. incoming
payments), automatically rebuild the will so the inheritance covers the
new UTXOs. The delivery date is anticipated by one day to orphan the old
will on-chain; an on-chain invalidation is only needed when the anticipated
locktime crosses the Check Alive threshold.
**What changed:**
- `bal/core/plugin_base.py`
- New persisted config `AUTO_REBUILD = BalConfig(config,
"bal_auto_rebuild", False)` (default OFF), with explanatory comment.
- `bal/gui/qt/plugin.py`
- New "Auto-rebuild" checkbox in the settings dialog, bound to
`AUTO_REBUILD`, with a tooltip. Added to the "Reset to Default Setting"
list.
- `bal/gui/qt/window.py`
- New `_auto_rebuild_on_new_tx()` method: triggered when Electrum detects
a new transaction in the wallet. Runs the full prepare flow: check
coherence, rebuild with the anticipated locktime (delivery date minus 1
day), persist, sign (if passwordless), push to will-executors.
- When the anticipated locktime crosses the Check Alive threshold, returns
an invalidation transaction instead of auto-completing.
- Connected to Electrum's `new_transaction` signal.
- `tests/test_auto_rebuild_on_new_tx.py` (new)
- 16 tests: AUTO_REBUILD default OFF, toggle/persist, rebuild triggers on
new tx, locktime anticipation by 1 day, threshold crossing returns
invalidation, passwordless wallet signs automatically, encrypted wallet
returns invalidation tx for manual signing.
**Verification:**
- `ruff check` on changed files: no new errors.
- Full test suite: 432 passed.
**Outcome:** DONE.
---
## 54. CLI `bal_will_autorebuild` command
**Date:** 2026-08-16
**Goal:** expose the auto-rebuild flow (entry #53) as a headless CLI command,
so scripts and daemons can trigger the one-shot check/rebuild/sign/push
cycle without the Qt GUI.
**What changed:**
- `bal/cli/commands.py`
- New `bal_will_autorebuild` async command (flag `nw`): runs the full
auto-rebuild flow via `BalController.auto_rebuild()`. Returns a JSON
object with `result` (`valid`, `no_heirs`, `invalidated`, `nothing`,
`needs_signing`, `rebuilt`) and, when applicable, the invalidation
transaction.
- `bal/cli/controller.py`
- New `auto_rebuild()` method: headless replica of the GUI auto-rebuild
flow. Checks coherence, rebuilds with anticipated locktime, handles
threshold-crossing (returns invalidation tx), signs passwordless wallets
automatically, pushes to will-executors.
- `tests/test_cli_autorebuild.py` (new)
- 10 tests: auto-rebuild returns `valid` when will is coherent, `rebuilt`
when rebuilt, `invalidated` with invalidation tx when threshold crossed,
`needs_signing` for encrypted wallets, `no_heirs` when heirs are missing.
- `tests/test_cli_commands_registered.py`
- Updated `EXPECTED_COMMANDS` to include `bal_will_autorebuild`.
- `tests/test_cli_controller_offline.py`
- Extended with auto-rebuild flow tests.
**Verification:**
- `ruff check` on changed files: no new errors.
- Full test suite: 438 passed.
**Outcome:** DONE.
---
## 55. Remove "Add transaction without willexecutor" from settings dialog
**Date:** 2026-08-17
**Goal (owner request):** the "Add transaction without willexecutor" checkbox
was already available in the Will-Executor tab; showing it redundantly in the
settings dialog created confusion. Remove it from the settings dialog and
renumber the grid rows.
**What changed:**
- `bal/gui/qt/plugin.py`
- Removed the "Add transaction without willexecutor" checkbox
(`NO_WILLEXECUTOR`) from the settings dialog grid. The setting is still
functional (available from the Will-Executor tab and the wizard); only
the settings-dialog exposure was removed.
- Grid rows 5--16 renumbered to 4--15 to close the gap left by the removal.
- Removed the corresponding reset-button widget for `NO_WILLEXECUTOR` from
the "Reset to Default Setting" list.
**Verification:**
- `ruff check` on changed file: no new errors.
- Full test suite: 438 passed (unchanged).
**Outcome:** DONE.
---
## 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

@@ -10,7 +10,7 @@
## 0. TL;DR — what this project is ## 0. TL;DR — what this project is
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the - **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
**Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**). **Electrum 4.7.2 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user - **Form:** external **ZIP plugin** (not bundled in Electrum). The user
installs the ZIP from Electrum's plugin manager. installs the ZIP from Electrum's plugin manager.
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast - **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
@@ -57,26 +57,39 @@ These are non-negotiable. They come from the owner directly.
bal/ <- the plugin package (this is what ships in the ZIP) bal/ <- the plugin package (this is what ships in the ZIP)
__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
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,
prepare_transactions, buildTransactions). CORE LOGIC. prepare_transactions, buildTransactions). CORE LOGIC.
will.py <- Will/WillItem, validation (check_amounts, check_will), will.py <- Will/WillItem, validation (check_amounts, check_will),
exceptions (AmountException, WillExpiredException, ...). exceptions (AmountException, WillExpiredException, ...).
willexecutors.py <- remote will-executor services handling. willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
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.
dialogs.py <- the big build/sign/broadcast dialog dialogs.py <- the big build/sign/broadcast dialog
(BalBuildWillDialog, task_phase1/2), wizard glue. (BalBuildWillDialog, task_phase1/2), wizard glue.
widgets.py <- WillSettingsWidget + wizard widgets/labels. widgets.py <- WillSettingsWidget + wizard widgets/labels.
window.py <- BalWalletWindow (build_will, check_will, get_transactions). window.py <- BalWindow, the per-wallet controller (build_will, check_will,
lists.py, calendar.py, theme.py, window_utils.py, ... get_transactions, merge_will, on_close, menubar wiring).
tests/ <- pytest suite (see run command below). plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
electrum-src/ <- a copy of Electrum source, used ONLY for tests lists.py, calendar.py, theme.py (status colours), window_utils.py
(PYTHONPATH=electrum-src). NOT shipped in the ZIP. wallet_util/ <- standalone wallet-inspection helpers, no Qt
build_zip.py <- builds the shippable ZIP (37 files). tests/ <- standalone test scripts (see Section 3).
docs/ <- user manual + inheritance-options guide (.md sources).
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.
HANDOFF.md <- this file. HANDOFF.md <- this file.
@@ -86,32 +99,56 @@ HANDOFF.md <- this file.
## 3. How to build, test and lint ## 3. How to build, test and lint
Run everything from `/home/user/webapp`. Two separate venvs — using the wrong one is the #1 mistake:
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source "$BAL_HOME/electrum/env/bin/activate"` — an editable
install of the Electrum **4.8.0** checkout at
`$BAL_HOME/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests. The plugin's `bal/` directory is
symlinked into `electrum/electrum/plugins/bal` (internal-plugin install used
during dev).
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
Run everything from the repo root.
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
**Full test suite (expected: 266 passed as of v0.4.8):**
```bash ```bash
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \ source "$BAL_HOME/electrum/env/bin/activate"
tests/test_core_*.py tests/test_gui_*.py \ python3 tests/test_core_heirs.py # core, no Qt needed
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py -q
``` ```
Most core tests run offline (no wallet/network). Some files
(`tests/test_group_*.py`, `tests/test_no_willexecutor_karen7.py`,
`parallel_ping_test.py`) exercise will-executor/network flows and need the live
servers — don't rely on them for quick verification.
**Current state of the suite: 427 tests collected.** The offline subset passes
(414 passed) apart from pre-existing failures that are NOT yours to fix without
asking: 13 failures in `tests/test_core_will_invalidate.py` (a `None` fee when a
UTXO has no fee value, `bal/core/will.py:482`) and 1 collection error in
`tests/test_group_i_basic_checkalive.py` (missing
`BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS`).
**Lint (only NEW errors matter; ignore pre-existing noise):** **Lint (only NEW errors matter; ignore pre-existing noise):**
```bash ```bash
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \ ./venv/bin/ruff check <files> \
| grep -vE "F401|F403|F405|F841" | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
``` ```
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
`tests/`); do NOT run `--fix` wholesale — just avoid adding new violations.
Pre-existing, KNOWN-OK noise: `F401/F403/F405` (star-imports via
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks). `from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
Do NOT "fix" these unless asked — they are intentional / out of scope. Do NOT "fix" these unless asked — they are intentional / out of scope.
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):** **Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
```bash ```bash
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256; 36 files)
``` ```
**Bump version — ONE file only (single source of truth):** **Bump version — ONE file only (single source of truth):**
@@ -124,6 +161,10 @@ The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py`
must **fully restart Electrum** (not just reload the plugin) — Electrum's must **fully restart Electrum** (not just reload the plugin) — Electrum's
`zipimport` caches modules, so a partial reload runs stale code. `zipimport` caches modules, so a partial reload runs stale code.
**Automated release:** use `./make-release.sh` to run the full release flow
(tests, lint, build, GPG sign, SHA-256, Electrum test pause, Gitea release).
See Section 5 for details.
--- ---
## 4. Key technical knowledge (hard-won — saves you hours) ## 4. Key technical knowledge (hard-won — saves you hours)
@@ -222,25 +263,49 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
## 5. Git / delivery workflow ## 5. Git / delivery workflow
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`. - **Branch:** work directly on `main` (no PR flow anymore). Push straight to
`origin/main` (Gitea).
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it - **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
works, THEN commit. (This differs from "commit after every change"; the owner works, THEN commit. (This differs from "commit after every change"; the owner
explicitly prefers ZIP-first because they manually test each build.) explicitly prefers ZIP-first because they manually test each build.)
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve - Before pushing: check `git status`/`git diff`, stage only the intended files
conflicts preferring remote `main` unless a local change is essential, (never secrets), commit with a concise message, then push to `origin/main`.
squash local commits into ONE comprehensive commit, push (force if needed),
then create/update the PR and SHARE the PR URL with the owner.
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are - **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`). distributed via **Gitea Releases** using `make-release.sh`.
The newest release is the "Latest" and is the owner's convenient download. - **Release process** (`make-release.sh`):
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can 1. Version bump in `bal/manifest.json` (single source of truth)
download them directly from chat. 2. Clean `__pycache__` and `.pyc` files
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token", 3. Run full test suite
re-run the GitHub environment setup, then retry. 4. Lint with ruff (skip if not installed)
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section + 5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
translation), **#15** (v0.4.8). All merged into `main`. 6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`); 7. Export public key as `svatantrya.asc`
v0.4.7 kept in history. 8. SHA-256 checksum
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
- **Usage:**
```bash
./make-release.sh # read version from bal/manifest.json
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
```
- **Release assets** (5 files):
- `bal_vX.Y.Z.zip` — the plugin
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
- `svatantrya.asc` — signing public key
- **GPG verification instructions** (included in release body):
```bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
```
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
manifest.json version); all merged into `main`.
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
v0.2.x line is kept in history.
--- ---
@@ -269,6 +334,31 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button (#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`). renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
266 tests pass. 266 tests pass.
- **v0.5.1 — v0.5.10** — BASIC/ADVANCED ("user type") mode work: Windows
settings-dialog flicker fix; Check Alive shown read-only in BASIC; BASIC
builds the will against "now" (`date_to_check = now()`, Check Alive fully
ignored); ADVANCED defaults to RAW (1y/30d); consistent Raw/Date default per
mode; Check Alive red-highlight fixes; clearer "could not build the will"
message; CHECK no longer resets a manual Date/RAW choice.
- **v0.5.11** — Electrum **4.8.0** compatibility (the `json_db.register_dict`
DB-registration API was removed in 4.8; the plugin now supports 4.7.2 and
4.8.0).
- **v0.5.12 — v0.5.18** — Check Alive soft-red highlight removed; short Tor
(.onion) will-executor URLs; KeyError fix on .onion executor actions; skip
.onion executors from download when Electrum is not on Tor; crash fix on a
non-dict welist response; clearer message when the list download
fails/times out over Tor.
- **v0.6.0** — version bump for the official repository release.
- **v0.6.1** — version read from `bal/manifest.json` (single source of truth);
`bal/VERSION` file removed.
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
CHANGELOG).
- **v0.7.0** — OP_RETURN heirs; core extraction (checkalive, reminders,
input_rules); RLock pickle fix; `REBUILD_ON_CLOSE`; headless CLI layer
(`bal/cli/`, `bal/cmdline.py`, 30 `bal_*` commands); `AUTO_REBUILD` on new
transactions; `bal_will_autorebuild` CLI command; removed redundant
"Add transaction without willexecutor" from settings dialog.
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail) ### 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
@@ -290,12 +380,15 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`. 1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`. 2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`.
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8). 3. Run the offline test files (Section 3) — expect the offline subset to pass
(414 passed; the 13 pre-existing failures in `test_core_will_invalidate.py`
and the 1 collection error in `test_group_i_basic_checkalive.py` are NOT
yours to fix without asking).
4. Talk to the owner in **Italian**, write everything else in **English**. 4. Talk to the owner in **Italian**, write everything else in **English**.
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test, 5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
build a ZIP, let the owner test, and only commit after explicit confirmation. build a ZIP, let the owner test, and only commit after explicit confirmation.
6. Keep credit usage low: summarize, don't paste big code blocks; batch work. 6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`, 7. When the owner confirms a ZIP works: commit (ZIP-first) directly on `main`,
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub push to `origin/main`, then run `./make-release.sh` to create the Gitea
**Release** with the ZIP attached (it becomes the owner's "Latest" download). **Release** with the ZIP + signatures attached (it becomes the owner's
Always give the owner the PR URL and the Release URL. "Latest" download). Always give the owner the Release URL.

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,8 +39,9 @@ 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 VERSION 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
tests/ smoke + external-zip regression tests tests/ smoke + external-zip regression tests
``` ```
@@ -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
@@ -85,12 +131,17 @@ to broadcast it (they collect fees). Because the locktime is baked into the
signed transaction, simply changing the delivery time later is **not enough**: signed transaction, simply changing the delivery time later is **not enough**:
the old, already-signed transaction keeps living on the will-executors. the old, already-signed transaction keeps living on the will-executors.
The plugin handles the two cases as follows (triggered when you press The plugin handles the cases as follows (triggered when you press
**Tools → Prepare**): **Prepare** on the **WILL** tab):
* **Anticipate** (new delivery time *earlier* than the signed locktime): the * **Anticipate** (new delivery time *earlier* than the signed locktime, still
will is treated as expired and you are asked to **invalidate** the old in the future): a plain **rebuild** the transactions are re-created with
transaction on-chain, then rebuild. the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
even if the will was already signed/sent: moving the date earlier only makes
the inheritance available *sooner*, so there is no early-execution risk.
* **Expire** (new delivery time now in the **past**): the will is genuinely
expired and you are asked to **invalidate** the old transaction on-chain,
then rebuild.
* **Postpone** (new delivery time *later* than the signed locktime) on a will * **Postpone** (new delivery time *later* than the signed locktime) on a will
that was already **signed and/or pushed**: the previously committed coins that was already **signed and/or pushed**: the previously committed coins
must be invalidated on-chain **first**, otherwise a will-executor could must be invalidated on-chain **first**, otherwise a will-executor could
@@ -121,14 +172,15 @@ state.
## Testing ## Testing
Run the tests with the **runtime environment** active (see `HANDOFF.md` §3 for
the two venvs and how to activate them):
```bash ```bash
# imports + behavior # imports + behavior
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \ QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal
python3 tests/smoke_test.py electrum.plugins.bal
# external-zip loading regression # external-zip loading regression (run after build_zip.py)
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \ QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
python3 tests/external_zip_test.py bal-electrum-plugin.zip
``` ```
## ⚠️ Safety ## ⚠️ Safety

View File

@@ -12,8 +12,8 @@ servers.
already-signed will is handled safely. Postponing a signed/sent will first already-signed will is handled safely. Postponing a signed/sent will first
asks you to invalidate the old transaction on-chain (so a will-executor can asks you to invalidate the old transaction on-chain (so a will-executor can
never broadcast the earlier-locktime transaction and execute the inheritance never broadcast the earlier-locktime transaction and execute the inheritance
too early), then lets you rebuild and re-send the new one via too early), then lets you rebuild and re-send the new one via the
**Tools → Prepare**. **Prepare** button on the **WILL** tab.
- **"Server" column**: the will transaction list shows whether each transaction - **"Server" column**: the will transaction list shows whether each transaction
is actually stored on the will-executor servers is actually stored on the will-executor servers
(`Confirmed on server`, `Sent (not checked)`, `Send failed`, (`Confirmed on server`, `Sent (not checked)`, `Send failed`,

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)

111
bal/core/checkalive.py Normal file
View File

@@ -0,0 +1,111 @@
"""
bal.core.checkalive
===================
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
"""
from datetime import datetime, timezone
from typing import Any
from .plugin_base import BalTimestamp
class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
)
def resolve_date_to_check(
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
built_locktime: float | int | None = None,
) -> float:
"""Return the reference timestamp for every will-validity check.
``date_to_check`` is the single reference timestamp that EVERY downstream
check reads: the build filter, the heir count, ``check_will_expired``,
``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
will_settings: the per-wallet settings dict (``"threshold"`` and
``"locktime"`` keys).
now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet).
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
# the settings widget resolves it as real_threshold = locktime - N days.
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
# date_to_check into a moving target that disagrees with the fixed
# locktime of the built transactions (and with the date shown in the UI),
# which can wrongly mark the will as expired/postponed. Resolve it
# against the delivery date instead.
if threshold.unit is not None:
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
# No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool:
"""True when the Check Alive guard should fire (``CheckAliveError``).
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone or
rewrite of the will.
"""
if is_basic_mode:
return False
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
return date_to_check < current

View File

@@ -21,6 +21,8 @@ Will-executor "heirs" are synthetic entries whose key starts with the
``w!ll3x3c"`` marker; they are skipped by most heir comparisons. ``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
""" """
import asyncio
import inspect
import math import math
import random import random
import re import re
@@ -31,6 +33,7 @@ from typing import (
Dict, Dict,
Optional, Optional,
Tuple, Tuple,
cast,
) )
import dns import dns
@@ -65,6 +68,19 @@ if TYPE_CHECKING:
_logger = get_logger(__name__) _logger = get_logger(__name__)
def _query_txt_records(url: str) -> Tuple[Any, bool]:
"""Resolve TXT records with DNSSEC validation.
``electrum.dnssec.query`` became an ``async`` function in Electrum 4.7.2,
so adapt the call for this synchronous context while staying compatible
with older synchronous implementations.
"""
query = dnssec.query
if inspect.iscoroutinefunction(query):
return asyncio.run(query(url, dns.rdatatype.TXT))
return cast(Tuple[Any, bool], query(url, dns.rdatatype.TXT))
# Column layout of a stored heir list. These indices are part of the on-disk # Column layout of a stored heir list. These indices are part of the on-disk
# wallet format and are relied upon all over the codebase, so they must NEVER # wallet format and are relied upon all over the codebase, so they must NEVER
# be reordered. # be reordered.
@@ -75,6 +91,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...") HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
TRANSACTION_LABEL = "inheritance transaction" TRANSACTION_LABEL = "inheritance transaction"
OP_RETURN_PREFIX = "OP_RETURN:"
class AliasNotFoundException(Exception): class AliasNotFoundException(Exception):
pass pass
@@ -86,6 +104,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
output.value = math.floor((in_amount - fee) / out_amount * output.value) output.value = math.floor((in_amount - fee) / out_amount * output.value)
def is_op_return_address(address: str) -> bool:
return str(address).startswith(OP_RETURN_PREFIX)
def get_op_return_hex(address: str) -> Optional[str]:
if is_op_return_address(address):
return address[len(OP_RETURN_PREFIX):]
return None
def validate_op_return_hex(data_hex: str) -> None:
try:
data = bytes.fromhex(data_hex)
except ValueError:
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
if len(data) > 80:
raise NotAnAddress(
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
)
def create_op_return_script(data_hex: str) -> bytes: def create_op_return_script(data_hex: str) -> bytes:
"""Crea scriptpubkey OP_RETURN in bytes""" """Crea scriptpubkey OP_RETURN in bytes"""
data = bytes.fromhex(data_hex) data = bytes.fromhex(data_hex)
@@ -132,14 +171,22 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
heir[HEIR_REAL_AMOUNT] heir[HEIR_REAL_AMOUNT]
): ):
try: try:
real_amount = heir[HEIR_REAL_AMOUNT] if is_op_return_address(heir[HEIR_ADDRESS]):
outputs.append( data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
PartialTxOutput.from_address_and_value( op_return_script = create_op_return_script(data_hex)
heir[HEIR_ADDRESS], real_amount outputs.append(
PartialTxOutput(value=0, scriptpubkey=op_return_script)
) )
) description += f"{name}\n"
out_amount += real_amount else:
description += f"{name}\n" real_amount = heir[HEIR_REAL_AMOUNT]
outputs.append(
PartialTxOutput.from_address_and_value(
heir[HEIR_ADDRESS], real_amount
)
)
out_amount += real_amount
description += f"{name}\n"
except BitcoinException as e: except BitcoinException as e:
_logger.info("exception decoding output {} - {}".format(type(e), e)) _logger.info("exception decoding output {} - {}".format(type(e), e))
heir[HEIR_REAL_AMOUNT] = e heir[HEIR_REAL_AMOUNT] = e
@@ -174,7 +221,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
change = get_change_output(wallet, in_amount, out_amount, fee) change = get_change_output(wallet, in_amount, out_amount, fee)
if change: if change:
outputs.append(change) outputs.append(change)
for i in range(0, 100): for _ in range(0, 100):
random.shuffle(outputs) random.shuffle(outputs)
#op_return_text = "Hello Bal!" #op_return_text = "Hello Bal!"
@@ -230,6 +277,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
# TODO calculate de minimum inputs to be invalidated # TODO calculate de minimum inputs to be invalidated
def invalidate_inheritance_transactions(wallet): def invalidate_inheritance_transactions(wallet):
_logger.debug("invalidate tx in heir method")
# listids = [] # listids = []
utxos = {} utxos = {}
dtxs = {} dtxs = {}
@@ -249,7 +297,7 @@ def invalidate_inheritance_transactions(wallet):
del dtxs[txid] del dtxs[txid]
utxos = {} utxos = {}
for txid, tx in dtxs.items(): for _, tx in dtxs.items():
get_utxos_from_inputs(tx.inputs(), tx, utxos) get_utxos_from_inputs(tx.inputs(), tx, utxos)
utxos = sorted(utxos.items(), key=lambda item: len(item[1])) utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
@@ -264,39 +312,6 @@ def invalidate_inheritance_transactions(wallet):
remaining[key] = value remaining[key] = value
def print_transaction(heirs, tx, locktimes, tx_fees):
jtx = tx.to_json()
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
print("---")
for inp in jtx["inputs"]:
print(f"{inp['address']}: {inp['value_sats']}")
print("---")
for out in jtx["outputs"]:
heirname = ""
for key in heirs.keys():
heir = heirs[key]
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
jtx["locktime"]
):
heirname = key
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
print()
size = tx.estimated_size()
print(
"fee: {}\texpected: {}\tsize: {}".format(
tx.input_value() - tx.output_value(), size * tx_fees, size
)
)
print()
try:
print(tx.serialize_to_network())
except Exception:
print("impossible to serialize")
print()
def get_change_output(wallet, in_amount, out_amount, fee): def get_change_output(wallet, in_amount, out_amount, fee):
change_amount = int(in_amount - out_amount - fee) change_amount = int(in_amount - out_amount - fee)
if change_amount > wallet.dust_threshold(): if change_amount > wallet.dust_threshold():
@@ -348,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)
@@ -400,6 +419,9 @@ class Heirs(dict, Logger):
amount = 0 amount = 0
for key, v in heir_list.items(): for key, v in heir_list.items():
try: try:
if is_op_return_address(v[HEIR_ADDRESS]):
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
continue
column = HEIR_AMOUNT column = HEIR_AMOUNT
if real: if real:
column = HEIR_REAL_AMOUNT column = HEIR_REAL_AMOUNT
@@ -451,6 +473,12 @@ class Heirs(dict, Logger):
) )
) )
continue continue
if is_op_return_address(self[key][HEIR_ADDRESS]):
heir = list(self[key])
heir.insert(HEIR_REAL_AMOUNT, 0)
fixed_heirs[key] = heir
_logger.debug(f"OP_RETURN heir {key} excluded from amount calculation")
continue
if Util.is_perc(self[key][HEIR_AMOUNT]): if Util.is_perc(self[key][HEIR_AMOUNT]):
percent_amount += float(self[key][HEIR_AMOUNT][:-1]) percent_amount += float(self[key][HEIR_AMOUNT][:-1])
percent_heirs[key] = list(self[key]) percent_heirs[key] = list(self[key])
@@ -478,7 +506,8 @@ class Heirs(dict, Logger):
) )
def prepare_lists( def prepare_lists(
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
from_locktime=0, max_fee=None,
): ):
if balance<total_fees or balance < wallet.dust_threshold(): if balance<total_fees or balance < wallet.dust_threshold():
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees) raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
@@ -493,8 +522,12 @@ class Heirs(dict, Logger):
if int(Util.int_locktime(locktime)) > int(from_locktime): if int(Util.int_locktime(locktime)) > int(from_locktime):
try: try:
base_fee = int(willexecutor["base_fee"]) base_fee = int(willexecutor["base_fee"])
if max_fee is not None and base_fee > max_fee:
raise WillExecutorFeeTooHighException(
willexecutor, max_fee
)
willexecutors_amount += base_fee willexecutors_amount += base_fee
h = [None] * 4 h: list = [None] * 4
h[HEIR_AMOUNT] = base_fee h[HEIR_AMOUNT] = base_fee
h[HEIR_REAL_AMOUNT] = base_fee h[HEIR_REAL_AMOUNT] = base_fee
h[HEIR_LOCKTIME] = locktime h[HEIR_LOCKTIME] = locktime
@@ -586,6 +619,8 @@ class Heirs(dict, Logger):
heir[HEIR_REAL_AMOUNT] heir[HEIR_REAL_AMOUNT]
): ):
valid_real_heirs += 1 valid_real_heirs += 1
elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]):
valid_real_heirs += 1
if real_heirs > 0 and valid_real_heirs == 0: if real_heirs > 0 and valid_real_heirs == 0:
raise HeirAmountIsDustException( raise HeirAmountIsDustException(
"All heirs' shares are below the dust limit" "All heirs' shares are below the dust limit"
@@ -599,8 +634,35 @@ 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
): ):
Heirs._validate(self) # Reset the diagnostic reason at the start of every build attempt.
#
# WHY: when the build produced nothing, the GUI used to show a fixed
# list of three "possible reasons" (low balance / dust shares /
# check-alive after the delivery date). In practice the real cause is
# often NONE of those three - several code paths below simply return
# an empty result with no explanation at all, so the user was shown
# three guesses that were all wrong. Each such path now records WHY
# it gave up, and BalBuildWillDialog names the actual cause.
#
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
# WILLEXECUTOR_TX_ERROR.
self.last_build_error = None
_before = list(self.keys())
Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self]
if _removed:
# The build skips invalid heirs, but they are only dropped in memory
# (persist=False): the wallet still keeps them, so the user can fix
# or remove them deliberately instead of losing them silently.
_logger.warning(
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
"not removed): %s",
len(_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
@@ -612,35 +674,43 @@ class Heirs(dict, Logger):
self.decimal_point = bal_plugin.get_decimal_point() self.decimal_point = bal_plugin.get_decimal_point()
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get() no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
for utxo in utxos: for utxo in utxos:
if utxo.value_sats() > 0 * tx_fees: if utxo.value_sats() > 0:
balance += utxo.value_sats() balance += utxo.value_sats()
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:
break break
elif 0 <= j: elif 0 <= j:
url, willexecutor = willexecutorsitems[j] url, willexecutor = willexecutorsitems[j]
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
continue continue
else: else:
willexecutor["url"] = url willexecutor["url"] = url
elif j == -1: elif j == -1:
if not no_willexecutors: if not no_willexecutors:
continue continue
url = willexecutor = False url = willexecutor = None
else: else:
break break
processed_willexecutors += 1
fees = {} fees = {}
i = 0 i = 0
txs = {}
while i < 10: while i < 10:
txs = {} txs = {}
redo = False redo = False
@@ -651,9 +721,15 @@ class Heirs(dict, Logger):
# newbalance = balance # newbalance = balance
try: try:
locktimes, onlyfixed = self.prepare_lists( locktimes, onlyfixed = self.prepare_lists(
balance, total_fees, wallet, willexecutor, from_locktime balance, total_fees, wallet, willexecutor, from_locktime,
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except WillExecutorFeeException: except WillExecutorFeeException:
self.last_build_error = "WILLEXECUTOR_FEE"
i = 10
continue
except WillExecutorFeeTooHighException:
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
i = 10 i = 10
continue continue
if locktimes: if locktimes:
@@ -662,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",
(willexecutor or {}).get("url", "(none)"),
e,
) )
try: break
if "w!ll3x3c" in e.heirname:
Willexecutors.is_selected(
e.heirname[len("w!ll3x3c") :], False
)
break
except Exception:
raise e
total_fees = 0 total_fees = 0
total_fees_real = 0 total_fees_real = 0
total_in = 0 total_in = 0
@@ -698,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(
@@ -776,7 +880,7 @@ class Heirs(dict, Logger):
# support email-style addresses, per the OA standard # support email-style addresses, per the OA standard
url = url.replace("@", ".") url = url.replace("@", ".")
try: try:
records, validated = dnssec.query(url, dns.rdatatype.TXT) records, validated = _query_txt_records(url)
except DNSException as e: except DNSException as e:
_logger.info(f"Error resolving openalias: {repr(e)}") _logger.info(f"Error resolving openalias: {repr(e)}")
return None return None
@@ -785,60 +889,78 @@ class Heirs(dict, Logger):
string = to_string(record.strings[0], "utf8") string = to_string(record.strings[0], "utf8")
if string.startswith("oa1:" + prefix): if string.startswith("oa1:" + prefix):
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)") address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
if not address:
continue
name = cls.find_regex(string, r"recipient_name=([^;]+)") name = cls.find_regex(string, r"recipient_name=([^;]+)")
if not name: if not name:
name = address name = address
if not address:
continue
return address, name, validated return address, name, validated
@staticmethod @staticmethod
def find_regex(haystack, needle): def find_regex(haystack, needle) -> Optional[str]:
regex = re.compile(needle) regex = re.compile(needle)
try: try:
return regex.search(haystack).groups()[0] return regex.search(haystack).groups()[0]
except AttributeError: except AttributeError:
return None return None
@staticmethod
def validate_address(address): def validate_address(address):
if is_op_return_address(address):
data_hex = address[len(OP_RETURN_PREFIX):]
validate_op_return_hex(data_hex)
return address
if not bitcoin.is_address(address, net=constants.net): if not bitcoin.is_address(address, net=constants.net):
raise NotAnAddress(f"not an address,{address}") raise NotAnAddress(f"not an address,{address}")
return address return address
@staticmethod
def validate_amount(amount): def validate_amount(amount):
try: try:
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount) famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
if famount <= 0.00000001: if famount <= 0.00000001:
raise AmountNotValid(f"amount have to be positive {famount} < 0") raise AmountNotValid(f"amount have to be positive {famount} < 0")
except Exception as e: except Exception as e:
raise AmountNotValid(f"amount not properly formatted, {e}") raise AmountNotValid(f"amount not properly formatted, {e}") from e
return amount return amount
@staticmethod
def validate_locktime(locktime, timestamp_to_check=False): def validate_locktime(locktime, timestamp_to_check=False):
try: try:
if timestamp_to_check: if timestamp_to_check:
if Util.parse_locktime_string(locktime, None) < timestamp_to_check: if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
raise HeirExpiredException() raise HeirExpiredException()
except Exception as e: except Exception as e:
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
return locktime return locktime
@staticmethod
def validate_heir(k, v, timestamp_to_check=False): def validate_heir(k, v, timestamp_to_check=False):
address = Heirs.validate_address(v[HEIR_ADDRESS]) address = Heirs.validate_address(v[HEIR_ADDRESS])
amount = Heirs.validate_amount(v[HEIR_AMOUNT]) if is_op_return_address(v[HEIR_ADDRESS]):
amount = "0"
else:
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
return (address, amount, locktime) return (address, amount, locktime)
def _validate(data, timestamp_to_check=False): @staticmethod
def _validate(data, timestamp_to_check=False, persist=True):
for k, v in list(data.items()): for k, v in list(data.items()):
if k == "heirs": if k == "heirs":
return Heirs._validate(v, timestamp_to_check) return Heirs._validate(v, timestamp_to_check, persist)
try: try:
Heirs.validate_heir(k, v, timestamp_to_check) Heirs.validate_heir(k, v, timestamp_to_check)
except Exception as e: except Exception as e:
_logger.info(f"exception heir removed {e}") _logger.info(f"exception heir removed {e}")
data.pop(k) if persist:
data.pop(k)
else:
# Drop the invalid heir in memory only, so the overridden
# Heirs.pop (which calls save()) is not triggered: a build
# must not silently delete heirs from the wallet.
dict.pop(data, k)
return data return data
@@ -874,6 +996,19 @@ class WillExecutorFeeException(Exception):
return "WillExecutorFeeException: {} fee:{}".format( return "WillExecutorFeeException: {} fee:{}".format(
self.willexecutor["url"], self.willexecutor["base_fee"] self.willexecutor["url"], self.willexecutor["base_fee"]
) )
class WillExecutorFeeTooHighException(Exception):
def __init__(self, willexecutor, max_fee):
self.willexecutor = willexecutor
self.max_fee = max_fee
def __str__(self):
return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format(
self.willexecutor["url"],
self.willexecutor["base_fee"],
self.max_fee,
)
class BalanceTooLowException(Exception): class BalanceTooLowException(Exception):
def __init__(self,balance, dust_threshold, fees): def __init__(self,balance, dust_threshold, fees):
self.balance=balance self.balance=balance

193
bal/core/input_rules.py Normal file
View File

@@ -0,0 +1,193 @@
"""
bal.core.input_rules
====================
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
amount field normalisation.
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
Keeping them here makes them testable without Qt and lets any GUI front-end
reuse the exact same parsing rules.
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional, Tuple, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
__all__ = [
"NLOCKTIME_BLOCKHEIGHT_MAX",
"NLOCKTIME_MAX",
"NLOCKTIME_MIN",
"LockTimeEditor",
"normalize_locktime_raw_text",
"normalize_perc_amount_text",
"parse_perc_amount",
"replace_dy_suffixes",
]
class LockTimeEditor:
"""Acceptance bounds shared by the RAW and Date locktime editors.
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
live here so they can be tested and reused without Qt. Widget subclasses
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
"""
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
"""True when ``x`` (string or int) is within the allowed locktime bounds.
An empty/falsy value is accepted (the field is not yet filled in).
"""
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
"""Highest locktime timestamp accepted on this platform.
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
"""
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is
# platform-dependent). see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
def replace_dy_suffixes(text: str) -> str:
"""Strip the relative-time suffixes (d/y) from ``text``.
Only days ("d") and years ("y") are supported. The block-height suffix
("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
When the character just before ``pos`` equals ``appendix``, the text is
re-normalised so only one suffix remains.
"""
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = replace_dy_suffixes(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def normalize_locktime_raw_text(
text: str, pos: int
) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text.
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
Args:
text: the raw field text.
pos: the cursor position within ``text``.
Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
text and ``new_pos`` the adjusted cursor position.
"""
text = text.strip()
chars = "0123456789dy"
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
isdays = False
isyears = False
pos, s = _checkbdy(s, pos, "d")
pos, s = _checkbdy(s, pos, "y")
if "d" in s:
isdays = True
if "y" in s:
isyears = True
if isdays:
s = replace_dy_suffixes(s) + "d"
if isyears:
s = replace_dy_suffixes(s) + "y"
return s, isdays, isyears, pos
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
"""Sanitise the amount-or-percentage field text.
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
as a percentage (``is_perc``). At most 8 decimal digits after the point.
Args:
text: the raw field text.
decimal_point: the decimal separator character (``electrum`` uses
``DECIMAL_POINT``, locale dependent).
Returns:
``(clean, is_perc)``.
"""
text = text.strip()
chars = "0123456789%"
chars += decimal_point
s = "".join([i for i in text if i in chars])
if "%" in s:
is_perc = True
s = s.replace("%", "")
else:
is_perc = False
if decimal_point in s:
p = s.find(decimal_point)
s = s.replace(decimal_point, "")
s = s[:p] + decimal_point + s[p : p + 8]
if is_perc:
s += "%"
return s, is_perc
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
"""Parse an amount-or-percentage field text into a numeric value.
Returns ``None`` when the text cannot be parsed.
"""
try:
text = text.replace(decimal_point, ".")
text = text.replace("%", "")
return Decimal(text)
except Exception:
return None

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__)
@@ -61,8 +62,9 @@ def get_version():
try: try:
import importlib.resources import importlib.resources
_parent_pkg = __package__.rpartition(".")[0] if __package__ else "bal"
data = ( data = (
importlib.resources.files("bal") importlib.resources.files(_parent_pkg)
.joinpath("manifest.json") .joinpath("manifest.json")
.read_text(encoding="utf-8") .read_text(encoding="utf-8")
) )
@@ -107,7 +109,7 @@ def get_will(x):
try: try:
# Electrum >= 4.8.0 # Electrum >= 4.8.0
from electrum.stored_dict import register_name as _electrum_register_name from electrum.stored_dict import register_name as _electrum_register_name # pyright: ignore[reportMissingImports]
def _register_will_dict(name, method, _type=None): def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API).""" """Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
@@ -117,7 +119,7 @@ except ImportError:
# Electrum <= 4.7.2 # Electrum <= 4.7.2
def _register_will_dict(name, method, _type=None): def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API).""" """Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
json_db.register_dict(name, method, _type) json_db.register_dict(name, method, _type) # pyright: ignore[reportAttributeAccessIssue]
_register_will_dict("heirs", tuple) _register_will_dict("heirs", tuple)
@@ -168,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)
@@ -227,6 +232,21 @@ class BalPlugin(BasePlugin):
self.PREVIEW = BalConfig(config, "bal_preview", True) self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) self.SAVE_TXS = BalConfig(config, "bal_save_txs", True)
# SAVE_HISTORY (history persistence): when enabled, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after every check, each with a configurable label. Default ON.
self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)
# HISTORY_LABEL: label text applied to the will transactions saved into
# the wallet's local history. May contain the "{willexecutor}" token,
# which is replaced with the will-executor URL of each will item at
# save time.
self.HISTORY_LABEL = BalConfig(
config,
"bal_history_label",
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after # AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
# querying the will-executor servers, automatically sign the will # querying the will-executor servers, automatically sign the will
# transactions and broadcast them to their will-executors, without the # transactions and broadcast them to their will-executors, without the
@@ -235,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
@@ -258,6 +296,9 @@ class BalPlugin(BasePlugin):
# follows what is saved in that wallet (the default only applies when no # follows what is saved in that wallet (the default only applies when no
# value has been stored yet, i.e. new wallets). # value has been stored yet, i.e. new wallets).
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False) self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
self.MAX_WILLEXECUTOR_FEE = BalConfig(
config, "bal_max_willexecutor_fee", 500000
)
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
@@ -287,7 +328,10 @@ class BalPlugin(BasePlugin):
config, "bal_event_summary", "BAL -Will execution of $wallet_name" config, "bal_event_summary", "BAL -Will execution of $wallet_name"
) )
# Default will-executor servers, keyed by network. # Default will-executor servers, keyed by network. These addresses are
# the ones currently reported by each server's <chain>/info endpoint and
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
# (bcrt1...) addresses, which are invalid on those networks.
self.WILLEXECUTORS = BalConfig( self.WILLEXECUTORS = BalConfig(
config, config,
"bal_willexecutors", "bal_willexecutors",
@@ -306,7 +350,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000, "base_fee": 100000,
"status": "New", "status": "New",
"info": "Bitcoin After Life Will Executor", "info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True, "selected": True,
} }
}, },
@@ -315,7 +359,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000, "base_fee": 100000,
"status": "New", "status": "New",
"info": "Bitcoin After Life Will Executor", "info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"selected": True, "selected": True,
} }
}, },
@@ -386,7 +430,7 @@ class BalPlugin(BasePlugin):
"""Fill in any missing will-setting with its default value.""" """Fill in any missing will-setting with its default value."""
defaults = BalPlugin.default_will_settings() defaults = BalPlugin.default_will_settings()
if not will_settings: if not will_settings:
will_settings = [] will_settings = {}
if int(will_settings.get("baltx_fees", 0)) < 1: if int(will_settings.get("baltx_fees", 0)) < 1:
will_settings["baltx_fees"] = defaults['baltx_fees'] will_settings["baltx_fees"] = defaults['baltx_fees']
if not will_settings.get("threshold"): if not will_settings.get("threshold"):
@@ -408,7 +452,7 @@ class BalPlugin(BasePlugin):
@staticmethod @staticmethod
def default_will_settings(): def default_will_settings():
"""Default will settings: a fee rate plus absolute threshold/locktime.""" """Default will settings: a fee rate plus absolute threshold/locktime."""
will_settings = {"baltx_fees": 20} will_settings: dict[str, float] = {"baltx_fees": 20}
will_settings.update(BalPlugin.default_will_settings_absolute()) will_settings.update(BalPlugin.default_will_settings_absolute())
return will_settings return will_settings
@@ -416,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()
@@ -441,10 +485,12 @@ class BalTimestamp:
* an integer -> an absolute UNIX timestamp (``unit is None``) * an integer -> an absolute UNIX timestamp (``unit is None``)
""" """
value = None value: int
unit = None unit: str | None
def __init__(self, value): def __init__(self, value):
self.value = 1
self.unit = None
str_value = str(value) str_value = str(value)
if str_value and str_value[-1].lower() in ("y", "d"): if str_value and str_value[-1].lower() in ("y", "d"):
self.value = int(str_value[:-1]) self.value = int(str_value[:-1])
@@ -473,14 +519,14 @@ class BalTimestamp:
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170). ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
""" """
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``.
@@ -493,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

228
bal/core/reminders.py Normal file
View File

@@ -0,0 +1,228 @@
"""
bal.core.reminders
==================
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
iCalendar (.ics) document.
Everything in this module is stdlib-only, so it can be imported and tested
without Electrum or Qt (e.g. in the lint venv).
"""
import os
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Optional
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[30, 16, 1]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
def format_time(time) -> str:
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def fold_ical_line(line: str, limit: int = 75) -> str:
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
multi-byte UTF-8 characters. Continuation lines start with a space."""
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# cut without splitting a UTF-8 continuation byte
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)
def ical_escape(text: str) -> str:
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
def write_temp_ics(content: str) -> str:
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
def build_ics_reminders(
*,
locktime: datetime,
basic_mode: bool,
description: str,
summary: str,
wallet_name: str,
heirs_details: str,
version: str,
num_reminders: int = 3,
now: Optional[datetime] = None,
threshold: Optional[datetime] = None,
) -> Optional[str]:
"""Build the ``.ics`` content with one VEVENT per reminder date.
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
user sees several distinct appointments in their calendar. The reminder
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
period).
Args:
locktime: the delivery deadline (datetime).
basic_mode: use the fixed BASIC offsets instead of spreading over the
check-alive period.
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
``$heirs_complete`` placeholders are substituted and escaped.
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
wallet_name: label used in the UID and template substitutions.
heirs_details: pre-formatted heir list injected into ``description``.
version: plugin version, embedded in the PRODID line.
num_reminders: requested reminder count (ADVANCED mode only).
now: "today" reference; defaults to ``datetime.now()``.
threshold: check-alive date (ADVANCED mode only; required there).
Returns:
The ``.ics`` content string, or ``None`` when no reminder falls in the
future (the delivery date is too close or already passed) so the caller
can show a warning instead of producing an empty-looking file.
"""
now = now if now is not None else datetime.now()
if basic_mode:
days_to_deadline = (locktime - now).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
if threshold is None:
raise ValueError("threshold is required in ADVANCED mode")
days = (locktime - threshold).days
offsets = compute_reminder_offsets(days, num_reminders)
# ToDo #2: no future reminder means there are no events to write. Return
# None so the caller shows a clear warning instead of an empty .ics file.
if not offsets:
return None
event_description = ical_escape(
f"{description}"
.replace("$wallet_name", str(wallet_name))
.replace("$heirs_complete", heirs_details)
)
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(wallet_name)}-{offset}d",
f"DTSTAMP:{format_time(now)}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{event_summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"

View File

@@ -18,8 +18,9 @@ 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.transaction import PartialTxOutput from electrum.transaction import PartialTxOutput
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is # Bitcoin consensus rule: an nLockTime value strictly below this threshold is
@@ -102,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":
@@ -130,6 +131,72 @@ class Util:
+ days * 60 * 60 * 24 + days * 60 * 60 * 24
) )
@staticmethod
def _relative_days(value):
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
Returns ``None`` when the value is not a relative recipe (an absolute
timestamp, a plain number, or garbage).
"""
s = str(value)
if s and s[-1] in "yYdD":
try:
n = int(s[:-1])
except ValueError:
return None
return n * 365 if s[-1] in "yY" else n
return None
@staticmethod
def resolve_locktime_against_tx(current, built, tx_locktime):
"""Resolve a locktime recipe against the moment the signed tx was built.
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
target: parsing it against *now* on every check drifts it one day per
day away from the fixed locktime frozen inside the signed Bitcoin
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
plugin asks to invalidate it every day (reported bug). This resolves
the current recipe against the build moment instead, recovered from the
signed transaction's locktime and the recipe that was actually frozen
at build time (``built``, the value stored in the will item):
build_moment = tx_locktime - duration(built)
expected = build_moment + duration(current)
An unchanged recipe therefore resolves to exactly ``tx_locktime``
(coherent), a lengthened one resolves later (postpone) and a shortened
one earlier (anticipate).
Args:
current: the current locktime recipe (relative or absolute).
built: the recipe frozen at build time (stored in the will item).
tx_locktime: the absolute locktime frozen inside the signed tx.
Returns:
int: the resolved absolute locktime (UNIX timestamp).
"""
current_days = Util._relative_days(current)
built_days = Util._relative_days(built)
if current_days is None:
# Absolute current date: compare directly against the frozen tx.
try:
return int(current)
except Exception:
return Util.parse_locktime_string(current)
if built_days is None or not tx_locktime:
# The stored recipe was absolute (a fixed date) or the tx has no
# usable locktime: there is no relative anchor to recover the build
# moment, so fall back to the legacy forward-from-now resolution.
return Util.parse_locktime_string(current)
try:
base = datetime.fromtimestamp(int(tx_locktime), tz=timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
)
build_moment = base - timedelta(days=built_days)
return int((build_moment + timedelta(days=current_days)).timestamp())
except Exception:
return Util.parse_locktime_string(current)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Amount helpers # Amount helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -373,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()
@@ -383,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 txid, 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)."""
@@ -425,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 txid, willitem in will.items():
utxos += willitem["tx"].inputs()
return utxos
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# UTXO helpers # UTXO helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -493,6 +506,106 @@ class Util:
return True return True
return False return False
@staticmethod
def get_available_utxos(wallet, history_label, will_locktime=None):
"""Return the wallet's UTXOs as seen by the plugin's flows.
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
marks as spent. The plugin itself creates such local spenders when it
saves an incomplete will transaction into the local history; a *later*
will transaction stored there (a replacement/future will with a locktime
strictly after ``will_locktime``) must not hide the coins from the will
being checked or rebuilt. This view therefore restores those coins.
A local spender is ignored (the coin is kept available) only when ALL of
these hold:
* it is a wallet-local or future transaction (not broadcast),
* its wallet label matches the BAL history label template (after the
"{willexecutor}" substitution),
* the stored spender's locktime is strictly LATER than ``will_locktime``.
Real (broadcast/confirmed) spenders are never ignored. With a falsy
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
Args:
wallet: The Electrum wallet object.
history_label: The BAL history label template (may contain
"{willexecutor}").
will_locktime: Reference locktime of the will being operated on.
"""
if not wallet or not will_locktime:
return list(wallet.get_utxos()) if wallet else []
adb = getattr(wallet, "adb", None)
if adb is None or not hasattr(adb, "get_addr_outputs"):
return list(wallet.get_utxos())
addresses = (
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
)
utxos = []
for addr in addresses:
try:
outputs = adb.get_addr_outputs(addr)
except Exception:
continue
for utxo in outputs.values():
if utxo.spent_height is None:
utxos.append(utxo)
continue
spender = getattr(utxo, "spent_txid", None)
if spender and Util._is_ignorable_local_spender(
wallet, spender, history_label, will_locktime
):
utxos.append(utxo)
return utxos
@staticmethod
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
"""True when the local ``spender`` tx is a later BAL history will tx.
See ``get_available_utxos`` for the exact conditions. Defensive: any
lookup failure makes this return False, so a spender is never ignored
on uncertain data.
"""
adb = wallet.adb
try:
height = int(adb.get_tx_height(spender).height())
except Exception:
return False
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return False
try:
label = wallet.get_label_for_txid(spender)
except Exception:
label = None
if not label or not Util._label_matches_history(label, history_label):
return False
try:
stored = adb.db.get_transaction(spender)
except Exception:
return False
if stored is None:
return False
try:
return int(stored.locktime) > int(will_locktime)
except Exception:
return False
@staticmethod
def _label_matches_history(label, history_label):
"""True when ``label`` is the ``history_label`` template with the
"{willexecutor}" token substituted by some (possibly empty) executor URL.
"""
token = "{willexecutor}"
if token in history_label:
prefix, suffix = history_label.split(token, 1)
return (
label.startswith(prefix)
and label.endswith(suffix)
and len(label) >= len(prefix) + len(suffix)
)
return label == history_label
@staticmethod @staticmethod
def cmp_output(outputa, outputb): def cmp_output(outputa, outputb):
"""Two outputs are equal when both address and value match.""" """Two outputs are equal when both address and value match."""

View File

@@ -40,9 +40,11 @@ from electrum.transaction import (
tx_from_any, tx_from_any,
) )
from electrum.util import ( from electrum.util import (
UnrelatedTransactionException,
bfh, bfh,
) )
from .heirs import WillExecutorFeeTooHighException
from .util import Util from .util import Util
from .willexecutors import Willexecutors from .willexecutors import Willexecutors
@@ -72,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():
@@ -105,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:
@@ -219,13 +207,8 @@ class Will:
if ow.we["url"] == nw.we["url"]: if ow.we["url"] == nw.we["url"]:
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]): if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
return anticipate return anticipate
else: elif int(ow.tx_fees) != int(nw.tx_fees):
if int(ow.tx_fees) != int(nw.tx_fees): return anticipate
return anticipate
else:
ow.tx.locktime
else:
ow.tx.locktime
else: else:
if nw.we == ow.we: if nw.we == ow.we:
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]): if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
@@ -446,19 +429,25 @@ class Will:
for wid in will: for wid in will:
wtx = will[wid].tx wtx = will[wid].tx
found = False found = False
inp = None
for inp in wtx.inputs(): for inp in wtx.inputs():
if inp.prevout.txid.hex() in will: if inp.prevout.txid.hex() in will:
found = True found = True
break break
if not found: if not found and inp is not None:
out[inp.prevout.to_str()] = inp out[inp.prevout.to_str()] = inp
return out return out
@staticmethod @staticmethod
def invalidate_will(will, wallet, fees_per_byte): def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
_logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will) will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid) inputs = Will.get_all_inputs(will_only_valid)
utxos = wallet.get_utxos() if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else:
utxos = wallet.get_utxos()
filtered_inputs = [] filtered_inputs = []
prevout_to_spend = [] prevout_to_spend = []
current_height = Util.get_current_height(wallet.network) current_height = Util.get_current_height(wallet.network)
@@ -472,11 +461,13 @@ class Will:
utxo_to_spend = [] utxo_to_spend = []
for utxo in utxos: for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100: if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
_logger.debug("is not mature coinbase output")
continue continue
utxo_str = utxo.prevout.to_str() utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend: if utxo_str in prevout_to_spend:
balance += inputs[utxo_str][0][2].value_sats() balance += inputs[utxo_str][0][2].value_sats()
utxo_to_spend.append(utxo) utxo_to_spend.append(utxo)
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0: if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction() change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
@@ -508,9 +499,10 @@ class Will:
@staticmethod @staticmethod
def is_new(will): def is_new(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):
@@ -534,10 +526,26 @@ class Will:
wi.set_status("INVALIDATED", True) wi.set_status("INVALIDATED", True)
else: else:
if wallet.db.get_transaction(wi._id): # The funding outpoint is not part of the will tree:
wi.set_status("CONFIRMED", True) # decide from whether a broadcast transaction really
else: # spends it (a wallet-local history copy of the same
# will tx must neither turn the item CONFIRMED nor
# INVALIDATED - it is just a persistence artifact).
stored = None
if wallet and getattr(wallet, "db", None):
try:
stored = wallet.db.get_transaction(wi._id)
except Exception:
stored = None
spender_height = Will._funding_spender_height(wallet, inp)
if spender_height is None:
if stored:
continue
wi.set_status("INVALIDATED", True) wi.set_status("INVALIDATED", True)
elif spender_height == 0:
wi.set_status("MEMPOOL", True)
else:
wi.set_status("CONFIRMED", True)
for child in wi.search(all_inputs): for child in wi.search(all_inputs):
if child.tx.locktime < wi.tx.locktime: if child.tx.locktime < wi.tx.locktime:
@@ -575,14 +583,22 @@ class Will:
for inp in w.tx.inputs(): for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp) inp_str = Util.utxo_to_str(inp)
if inp_str not in utxos_list: if inp_str not in utxos_list:
if wallet: if not wallet or not getattr(wallet, "adb", None):
height = Will.check_tx_height(w.tx, wallet) continue
if height < 0: height = Will.check_tx_height(w.tx, wallet)
if height < 0:
# The will tx itself is not on-chain. A missing
# funding UTXO is only a real problem when a
# broadcast transaction actually spends it; a
# wallet-local (history) copy of the same will tx
# marks the funding spent locally and must not
# invalidate the will.
if Will._funding_really_spent(wallet, inp_str):
Will.set_invalidate(wid, willtree) Will.set_invalidate(wid, willtree)
elif height == 0: elif height == 0:
w.set_status("MEMPOOL", True) w.set_status("MEMPOOL", True)
else: else:
w.set_status("CONFIRMED", True) w.set_status("CONFIRMED", True)
# def reflect_to_children(treeitem): # def reflect_to_children(treeitem):
# if not treeitem.get_status("VALID"): # if not treeitem.get_status("VALID"):
@@ -598,7 +614,8 @@ class Will:
# Will.reflect_to_children(wc) # Will.reflect_to_children(wc)
@staticmethod @staticmethod
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust,
max_fee=None):
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
) )
@@ -614,13 +631,92 @@ class Will:
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items(): for url, wex in willexecutors.items():
if Willexecutors.is_selected(wex): if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"]) temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance: if fixed_amount >= temp_balance:
raise FixedAmountException( raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
) )
@staticmethod
def _funding_really_spent(wallet, inp_str):
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
will tx from the local history) and future transactions, so this is True
only when the funding was consumed by a real on-chain/mempool tx.
"""
if not wallet or not getattr(wallet, "adb", None):
return False
try:
return wallet.adb.get_spender(inp_str) is not None
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return False
@staticmethod
def _funding_spender_height(wallet, inp_str):
"""Mined height of the broadcast tx spending ``inp_str``, or None.
Returns ``None`` when no broadcast transaction spends the outpoint (a
wallet-local history spender or a future tx are ignored by
``adb.get_spender``). The height is 0 for a mempool spender and positive
for a confirmed one.
"""
if not wallet or not getattr(wallet, "adb", None):
return None
try:
spender = wallet.adb.get_spender(inp_str)
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return None
if spender is None:
return None
try:
return int(wallet.adb.get_tx_height(spender).height())
except Exception as e:
_logger.error(f"get_tx_height failed for {spender}: {e}")
return 0
@staticmethod
def _absorb_history_signatures(will, wallet):
"""Merge signatures from the wallet's stored local copy of each will tx.
An incomplete will transaction saved into the local history (see
``save_valid_transactions_to_history``) may later accumulate signatures
(e.g. after a manual merge from a more complete copy). The in-memory
will item would otherwise miss those signatures on the next check. For
every item whose stored wallet copy is the same partial transaction the
signatures are merged into the in-memory one and, if it becomes fully
signed, the item is marked COMPLETE.
This method must never raise: history absorption is a convenience on top
of the will check, so any failure is logged and ignored.
"""
if not wallet or not getattr(wallet, "db", None):
return
for wi in will.values():
try:
if (
wi.tx is None
or not isinstance(wi.tx, PartialTransaction)
or wi.tx.is_complete()
):
continue
stored = wallet.db.get_transaction(wi._id)
if (
not isinstance(stored, Transaction)
or stored.txid() != wi.tx.txid()
):
continue
wi.tx.combine_with_other_psbt(stored)
if wi.tx.is_complete():
wi.set_status("COMPLETE", True)
except Exception as e:
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
@staticmethod @staticmethod
def check_will(will, all_utxos, wallet, timestamp_to_check): def check_will(will, all_utxos, wallet, timestamp_to_check):
"""Validate a will against the current wallet state. """Validate a will against the current wallet state.
@@ -636,6 +732,7 @@ class Will:
timestamp_to_check: The reference UNIX timestamp (usually "now") timestamp_to_check: The reference UNIX timestamp (usually "now")
used to decide whether any transaction has expired. used to decide whether any transaction has expired.
""" """
Will._absorb_history_signatures(will, wallet)
Will.add_willtree(will) Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos) utxos_list = Will.utxos_strs(all_utxos)
@@ -649,6 +746,186 @@ class Will:
Will.search_rai(all_inputs, all_utxos, will, wallet) Will.search_rai(all_inputs, all_utxos, will, wallet)
Will.check_signatures(will, wallet)
@staticmethod
def save_valid_transactions_to_history(will, wallet, history_label):
"""Keep the wallet's LOCAL history in sync with the current will state.
Called after the will has been built/signed/checked (see the
SAVE_HISTORY / HISTORY_LABEL settings). A will transaction belongs in the
local history while it is still "New" (not yet fully signed - i.e. an
incomplete partial transaction), and must be removed once it becomes
"Complete" (fully signed), because at that point it is ready to be
broadcast and will appear in the history on its own.
For every will item that is valid and whose transaction has a txid it:
1. decodes the label template, replacing "{willexecutor}" with the
will-executor URL of the item,
2. if the transaction is NOT complete, stores it via
``wallet.adb.add_transaction`` (merging signatures when an
already-stored partial transaction is upgraded by a more complete
one) and tags it with the decoded label,
3. if the transaction IS complete, does not store it: its matching
local-history entry is removed by the cleanup below.
Finally it deletes every wallet-local transaction whose label exactly
matches the decoded label of a current valid item but that is no longer
among the just-saved transactions, so fully-signed, rebuilt or replaced
wills do not pile up stale entries.
This method must never raise: history persistence is a convenience on
top of the will check, so any failure is logged and ignored.
Args:
will: The will dictionary (WillItem entries keyed by txid).
wallet: The Electrum wallet object (may be falsy for offline
checks, in which case this is a no-op).
history_label: The label template to apply (may contain
"{willexecutor}").
"""
if not wallet or not getattr(wallet, "adb", None):
return
saved_txids = []
try:
current_labels = {
history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
for wi in will.values()
if wi.get_status("VALID")
and wi.tx is not None
and wi.tx.txid() is not None
}
for wi in will.values():
if not wi.get_status("VALID"):
continue
if wi.tx is None or wi.tx.txid() is None:
continue
# Fully-signed (complete) transactions must NOT be saved: they
# are removed from the local history so the list does not show a
# placeholder for a transaction that will appear on its own once
# broadcast/confirmed. Only the not-yet-complete "New" items are
# stored. Note that fully-segwit partial txs have a txid even
# when incomplete, so the txid() check alone is not enough.
if wi.tx.is_complete():
continue
try:
txid = wi.tx.txid()
label = history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
Will._add_transaction_to_history(wallet, wi.tx, txid)
try:
wallet.set_label(txid, label)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
saved_txids.append(txid)
except Exception as e:
_logger.error(f"save to history failed for item {wi._id}: {e}")
# Delete stale wallet-local txs whose label matches a current valid
# item but that are no longer among the saved ones. This removes
# entries for fully-signed (complete) items and for rebuilt/replaced
# wills with the same executor.
for txid, label in Will._wallet_labels(wallet):
if txid in saved_txids:
continue
if label not in current_labels:
continue
try:
wallet.adb.remove_transaction(txid)
try:
wallet.set_label(txid, None)
except Exception:
pass
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history update: {e}")
except Exception as e:
_logger.error(f"save_valid_transactions_to_history failed: {e}")
@staticmethod
def _add_transaction_to_history(wallet, tx, txid):
"""Store *tx* into the wallet's local history via ``adb``.
If a partial transaction with the same txid is already stored and *tx*
carries additional signatures, the signatures are merged into the stored
one before saving. ``allow_unrelated`` is retried as a fallback so that
self-created txs (which are not yet part of the wallet's UTXO set) are
still accepted.
"""
adb = wallet.adb
existing = None
try:
existing = wallet.db.get_transaction(txid)
except Exception:
existing = None
try:
if (
isinstance(existing, PartialTransaction)
and not existing.is_complete()
and isinstance(tx, PartialTransaction)
):
existing.combine_with_other_psbt(tx)
adb.add_transaction(existing)
else:
try:
adb.add_transaction(tx)
except UnrelatedTransactionException:
adb.add_transaction(tx, allow_unrelated=True)
except Exception as e:
raise RuntimeError(f"add_transaction failed for {txid}: {e}") from e
@staticmethod
def _wallet_labels(wallet):
"""Return the wallet's ``(txid, label)`` pairs in a defensive way."""
try:
get_all_labels = wallet.get_all_labels
except AttributeError:
return []
try:
return list(get_all_labels().items())
except Exception as e:
_logger.error(f"get_all_labels failed: {e}")
return []
@staticmethod
def check_signatures(will, wallet=None):
"""Refresh the per-item signature counts and the PARTIALLY_SIGNED status.
The signature counts are derived from the transaction itself via
Electrum's ``signature_count()``, which needs a script descriptor on
each input (attached from the wallet when available). Items that already
carry their own descriptors (e.g. imported/merged partial transactions)
are counted even without a wallet.
An item with at least one signature present but fewer than required is
marked PARTIALLY_SIGNED. Items that are already signed (COMPLETE) or
whose transaction is complete always clear the flag.
"""
for wi in will.values():
try:
if wi.get_status("COMPLETE") or wi.tx is None or wi.tx.is_complete():
wi.set_status("PARTIALLY_SIGNED", False)
continue
if wallet:
wi.tx.add_info_from_wallet(wallet)
if not hasattr(wi.tx, "signature_count"):
continue
have, required = wi.tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
if required > 1 and 0 < have < required:
wi.set_status("PARTIALLY_SIGNED", True)
else:
wi.set_status("PARTIALLY_SIGNED", False)
except Exception as e:
_logger.error(f"check_signatures failed for item {wi._id}: {e}")
@staticmethod @staticmethod
def get_min_locktime(will,default_value=None): def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@@ -777,7 +1054,7 @@ class Will:
timestamp_to_check: Reference UNIX timestamp (usually "now"). timestamp_to_check: Reference UNIX timestamp (usually "now").
""" """
_logger.info("check if some transaction is expired") _logger.info("check if some transaction is expired")
for prevout_str, wid in all_inputs_min_locktime.items(): for _inputs, wid in all_inputs_min_locktime.items():
for w in wid: for w in wid:
if w[1].get_status("VALID"): if w[1].get_status("VALID"):
locktime = int(wid[0][1].tx.locktime) locktime = int(wid[0][1].tx.locktime)
@@ -854,8 +1131,6 @@ class Will:
if heir := heirs.get(wheir, None): if heir := heirs.get(wheir, None):
if heir[0] == their[0] and heir[1] == their[1]: if heir[0] == their[0] and heir[1] == their[1]:
# The requested (possibly new) locktime for this heir.
new_locktime = Util.parse_locktime_string(heir[2])
# IMPORTANT: compare against the locktime that is # IMPORTANT: compare against the locktime that is
# actually frozen inside the already-signed Bitcoin # actually frozen inside the already-signed Bitcoin
# transaction (w.tx.locktime), NOT against their[2]. # transaction (w.tx.locktime), NOT against their[2].
@@ -866,6 +1141,16 @@ class Will:
# undetected. w.tx.locktime is immutable once signed # undetected. w.tx.locktime is immutable once signed
# and is exactly what the will-executors hold. # and is exactly what the will-executors hold.
tx_locktime = int(w.tx.locktime) tx_locktime = int(w.tx.locktime)
# The requested (possibly new) locktime for this heir.
# A RELATIVE recipe ("1y"/"30d") is resolved against
# the moment the signed tx was built, NOT against now:
# re-parsing it from "now" drifts it one day per day
# away from the frozen tx locktime, so an UNCHANGED
# will would be read as a POSTPONE and the plugin
# would ask to invalidate it every day.
new_locktime = Util.resolve_locktime_against_tx(
heir[2], their[2], tx_locktime
)
if new_locktime == tx_locktime: if new_locktime == tx_locktime:
# Unchanged: this heir is still coherent. # Unchanged: this heir is still coherent.
count = heirs_found.get(wheir, 0) count = heirs_found.get(wheir, 0)
@@ -937,7 +1222,7 @@ class Will:
if self_willexecutor and no_willexecutor == 0: if self_willexecutor and no_willexecutor == 0:
raise NoWillExecutorNotPresent("Backup tx") raise NoWillExecutorNotPresent("Backup tx")
for url, we in willexecutors.items(): for url, we in willexecutors.items():
if Willexecutors.is_selected(we): if Willexecutors.is_selected(we) and Willexecutors.is_valid(we):
if url not in willexecutors_found: if url not in willexecutors_found:
_logger.debug(f"will-executor: {url} not fount") _logger.debug(f"will-executor: {url} not fount")
raise WillExecutorNotPresent(url) raise WillExecutorNotPresent(url)
@@ -974,6 +1259,7 @@ class WillItem(Logger):
"MEMPOOL": ["Mempool", False], "MEMPOOL": ["Mempool", False],
"PUSH_FAIL": ["Push failed", False], "PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False], "PUSHED": ["Pushed", False],
"PARTIALLY_SIGNED": ["Partially Signed", False],
"REPLACED": ["Replaced", False], "REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False], "RESTORED": ["Restored", False],
"UPDATED": ["Updated", False], "UPDATED": ["Updated", False],
@@ -1032,6 +1318,9 @@ class WillItem(Logger):
self.STATUS["PUSHED"][1] = True self.STATUS["PUSHED"][1] = True
self.STATUS["PUSH_FAIL"][1] = False self.STATUS["PUSH_FAIL"][1] = False
if status in ["COMPLETE"]:
self.STATUS["PARTIALLY_SIGNED"][1] = False
return value return value
def get_status(self, status): def get_status(self, status):
@@ -1043,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)
@@ -1052,6 +1343,8 @@ class WillItem(Logger):
self.time = w.get("time", None) self.time = w.get("time", None)
self.change = w.get("change", None) self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0) self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None) self.father = w.get("Father", None)
self.children = w.get("Children", None) self.children = w.get("Children", None)
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
@@ -1088,6 +1381,8 @@ class WillItem(Logger):
"time": self.time, "time": self.time,
"change": self.change, "change": self.change,
"baltx_fees": self.tx_fees, "baltx_fees": self.tx_fees,
"sigs_required": self.sigs_required,
"sigs_have": self.sigs_have,
} }
for key in self.STATUS: for key in self.STATUS:
try: try:

View File

@@ -17,8 +17,11 @@ interaction is handled by the Qt layer.
import json import json
import time import time
from datetime import datetime from datetime import datetime
from typing import Any
from aiohttp import ClientResponse from aiohttp import ClientResponse
from electrum import bitcoin, constants
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import Network from electrum.network import Network
@@ -109,8 +112,6 @@ def is_tor_active():
return False return False
chainname = BalPlugin.chainname
class Willexecutors: class Willexecutors:
@@ -143,19 +144,19 @@ 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)
@staticmethod @staticmethod
def get_willexecutors( def get_willexecutors(
bal_plugin, update=False, bal_window=False, 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):
@@ -169,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")
@@ -214,6 +215,20 @@ class Willexecutors:
willexecutor["selected"] = False willexecutor["selected"] = False
return False return False
@staticmethod
def is_valid(willexecutor, max_fee=None, dust=None):
if not willexecutor:
return False
address = willexecutor.get("address", "")
if not address or not bitcoin.is_address(address, net=constants.net):
return False
base_fee = int(willexecutor.get("base_fee", 0))
if dust is not None and base_fee < dust:
return False
if max_fee is not None and base_fee > max_fee:
return False
return True
@staticmethod @staticmethod
def get_willexecutor_transactions(will, force=False): def get_willexecutor_transactions(will, force=False):
willexecutors = {} willexecutors = {}
@@ -276,51 +291,44 @@ class Willexecutors:
headers["Content-Type"] = "text/plain" headers["Content-Type"] = "text/plain"
if not handle_response: if not handle_response:
handle_response = Willexecutors.handle_response handle_response = Willexecutors.handle_response
try: attempts = max_retries + 1
if method == "get": for attempt in range(attempts):
response = Network.send_http_on_proxy( try:
method, if method == "get":
url, response = Network.send_http_on_proxy(
params=data, method,
headers=headers, url,
on_finish=handle_response, params=data,
timeout=timeout, headers=headers,
) on_finish=handle_response,
elif method == "post": timeout=timeout,
response = Network.send_http_on_proxy( )
method, elif method == "post":
url, response = Network.send_http_on_proxy(
body=data, method,
headers=headers, url,
on_finish=handle_response, body=data,
timeout=timeout, headers=headers,
) on_finish=handle_response,
else: timeout=timeout,
raise Exception(f"unexpected {method=!r}") )
except TimeoutError: else:
if count_reply < max_retries: raise Exception(f"unexpected {method=!r}")
_logger.debug( _logger.debug(f"--> {response}")
f"timeout({count_reply}) error: retry in {retry_sleep} sec..." return response
) except TimeoutError:
if retry_sleep: if attempt < max_retries:
time.sleep(retry_sleep) _logger.debug(
return Willexecutors.send_request( f"timeout({attempt}) error: "
method, f"retry in {retry_sleep} sec..."
url, )
data, if retry_sleep:
timeout=timeout, time.sleep(retry_sleep)
handle_response=handle_response, else:
count_reply=count_reply + 1, _logger.debug(f"Too many timeouts: {attempt}")
max_retries=max_retries, except Exception as e:
retry_sleep=retry_sleep, raise e
) return None
else:
_logger.debug(f"Too many timeouts: {count_reply}")
except Exception as e:
raise e
else:
_logger.debug(f"--> {response}")
return response
@staticmethod @staticmethod
def get_we_url_from_response(resp): def get_we_url_from_response(resp):
@@ -331,15 +339,11 @@ class Willexecutors:
@staticmethod @staticmethod
async def handle_response(resp: ClientResponse): async def handle_response(resp: ClientResponse):
resp.raise_for_status()
r = await resp.text() r = await resp.text()
try: try:
r = json.loads(r) r = json.loads(r)
# url = Willexecutors.get_we_url_from_response(resp) except json.JSONDecodeError:
# r["url"]= url
# r["status"]=resp.status
except Exception as e:
_logger.debug(f"error handling response:{e}")
pass pass
return r return r
@@ -362,23 +366,25 @@ 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,
retry_sleep=retry_sleep, retry_sleep=retry_sleep,
): ):
willexecutor["broadcast_status"] = _("Success")
_logger.debug(f"pushed: {w}") _logger.debug(f"pushed: {w}")
if w != "thx": if w != "thx":
_logger.debug(f"error: {w}") _logger.debug(f"error: {w}")
raise Exception(w) raise Exception(w)
willexecutor["broadcast_status"] = _("Success")
else: else:
raise Exception("empty reply from:{willexecutor['url']}") raise Exception(
f"empty reply from:{willexecutor['url']}"
)
except Exception as e: except Exception as e:
_logger.debug(f"error:{e}") _logger.debug(f"error:{e}")
if str(e) == "already present": if str(e) == "already present":
raise Willexecutors.AlreadyPresentException() raise Willexecutors.AlreadyPresentException() from None
out = False out = False
willexecutor["broadcast_status"] = _("Failed") willexecutor["broadcast_status"] = _("Failed")
@@ -400,15 +406,38 @@ 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):
willexecutor["url"] = url address = w.get("address")
willexecutor["status"] = 200 if not isinstance(address, str) or not bitcoin.is_address(
willexecutor["base_fee"] = w["base_fee"] address, net=constants.net
willexecutor["address"] = w["address"] ):
willexecutor["info"] = w["info"] _logger.warning(
f"invalid address from {url}: {address!r}"
)
willexecutor["status"] = "KO"
else:
base_fee = w.get("base_fee")
try:
base_fee = int(base_fee or 0)
if base_fee < 0:
raise ValueError("negative fee")
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
raise ValueError("fee exceeds total coin supply")
except (TypeError, ValueError) as e:
_logger.warning(
f"invalid base_fee from {url}: "
f"{w.get('base_fee')!r} ({e})"
)
willexecutor["status"] = "KO"
else:
willexecutor["url"] = url
willexecutor["status"] = 200
willexecutor["base_fee"] = base_fee
willexecutor["address"] = address
willexecutor["info"] = w.get("info", "")
else: else:
# No dict reply (timeout / empty) -> mark as unreachable. # No dict reply (timeout / empty) -> mark as unreachable.
willexecutor["status"] = "KO" willexecutor["status"] = "KO"
@@ -451,8 +480,7 @@ class Willexecutors:
Returns: Returns:
The same ``willexecutors`` mapping, updated in place. The same ``willexecutors`` mapping, updated in place.
""" """
from concurrent.futures import ThreadPoolExecutor, wait from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
items = list(willexecutors.items()) items = list(willexecutors.items())
if not items: if not items:
@@ -532,8 +560,7 @@ class Willexecutors:
Returns ``{url: (ok, exception_or_None)}`` for the servers that Returns ``{url: (ok, exception_or_None)}`` for the servers that
answered in time (timed-out servers are reported via ``on_timeout``). answered in time (timed-out servers are reported via ``on_timeout``).
""" """
from concurrent.futures import ThreadPoolExecutor, wait from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
results = {} results = {}
@@ -650,8 +677,7 @@ class Willexecutors:
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
that answered in time. that answered in time.
""" """
from concurrent.futures import ThreadPoolExecutor, wait from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
targets = [(wid, url) for wid, url in items if url] targets = [(wid, url) for wid, url in items if url]
results = {} results = {}
@@ -742,7 +768,14 @@ class Willexecutors:
else: else:
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko")) willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False) willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address","")) address = old_willexecutor.get("address", willexecutor.get("address", ""))
if address and not bitcoin.is_address(address, net=constants.net):
_logger.warning(
f"invalid address {address!r} for executor {url}, "
f"falling back to empty"
)
address = ""
willexecutor["address"] = address
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code")) willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
@@ -753,16 +786,25 @@ 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",
) )
# del willexecutors["status"] if not isinstance(willexecutors, dict):
_logger.warning(
f"unexpected download_list response type: "
f"{type(willexecutors).__name__}"
)
return {}
for w in willexecutors: for w in willexecutors:
if w not in ("status", "url"): if w not in ("status", "url"):
if not isinstance(willexecutors.get(w), dict):
_logger.warning(
f"malformed entry {w!r} in executor list, "
f"type={type(willexecutors.get(w)).__name__}"
)
continue
Willexecutors.initialize_willexecutor( Willexecutors.initialize_willexecutor(
willexecutors[w], w, None, old_willexecutors.get(w,None) willexecutors[w], w, None, old_willexecutors.get(w,None)
) )
# bal_plugin.WILLEXECUTORS.set(l)
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
return willexecutors return willexecutors
except Exception as e: except Exception as e:
@@ -794,6 +836,12 @@ class Willexecutors:
"post", url + "/searchtx", data=txid.encode("ascii"), "post", url + "/searchtx", data=txid.encode("ascii"),
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
) )
if not isinstance(w, dict):
_logger.warning(
f"unexpected check_transaction response type "
f"from {url}: {type(w).__name__}"
)
return None
return w return w
except Exception as e: except Exception as e:
_logger.error(f"error contacting {url} for checking txs {e}") _logger.error(f"error contacting {url} for checking txs {e}")

View File

@@ -7,12 +7,22 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application. into an RFC-5545 .ics file and opens it with the OS default application.
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
the Qt button and the OS/subprocess glue.
""" """
from .common import * import os
from .common import _, _logger # underscore names are not re-exported by "import *" 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 .common import _, _logger
class BalCalendarButton(QToolButton): class BalCalendarButton(QToolButton):
@@ -56,7 +66,7 @@ class BalCalendarButton(QToolButton):
try: try:
content = self._ics_provider() content = self._ics_provider()
if content: if content:
self._calendar_temp_path = BalCalendar.write_temp_ics(content) self._calendar_temp_path = write_temp_ics(content)
else: else:
self._calendar_temp_path = None self._calendar_temp_path = None
self._bal_window.show_warning( self._bal_window.show_warning(
@@ -79,7 +89,8 @@ class BalCalendarButton(QToolButton):
path = self._ensure_ics() path = self._ensure_ics()
if not path: if not path:
return return
import shlex, subprocess import shlex
import subprocess
if self._bal_window.bal_plugin.is_basic_mode(): if self._bal_window.bal_plugin.is_basic_mode():
app = self._bal_window.bal_plugin.CALENDAR_APP.default app = self._bal_window.bal_plugin.CALENDAR_APP.default
else: else:
@@ -147,13 +158,6 @@ class BalCalendarButton(QToolButton):
class BalCalendar: class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod @staticmethod
def open_with_default_app(calendar_app, path): def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app") _logger.debug("opening calendar app")
@@ -182,52 +186,3 @@ class BalCalendar:
if os.path.isdir(desktop): if os.path.isdir(desktop):
return desktop return desktop
return home return home
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def ical_escape(text: str) -> str:
# escape per RFC5545: backslash, ; , newlines
text = text.encode("utf-8")
text = (
text.replace(b"\\", b"\\\\")
.replace(b";", b"\\;")
.replace(b",", b"\\,")
)
out =""
temp=text.split(b"\r\n")
for s in temp:
encoded= s
cut =0
while len(encoded) >75:
cut+=5
encoded=f"{s[:len(s)-cut]}"
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
cut += 1
encoded=f"{s[:len(s)-cut]}"
encoded=f"{encoded}...\r\n".encode("utf-8")
if cut>0:
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
else:
out+=str(f"{s.decode()}\r\n")
return out[:-2]
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# taglia senza spezzare byte UTF-8
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)

View File

@@ -12,7 +12,7 @@ hosts a few GUI helpers that do not deserve a module of their own:
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid. * :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
* :func:`log_error` - format an exception traceback for a dialog. * :func:`log_error` - format an exception traceback for a dialog.
* :func:`export_meta_gui` - export plugin metadata to a JSON file. * :func:`export_meta_gui` - export plugin metadata to a JSON file.
* :class:`CheckAliveError`- raised when the "check alive" date is in the past. (:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
""" """
import copy import copy
@@ -27,62 +27,139 @@ from decimal import Decimal
from functools import partial from functools import partial
from typing import Any, Callable, Mapping, Optional, Union from typing import Any, Callable, Mapping, Optional, Union
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
NLOCKTIME_MIN)
from electrum.gui.qt.amountedit import BTCAmountEdit from electrum.gui.qt.amountedit import BTCAmountEdit
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
from electrum.gui.qt.my_treeview import MyTreeView from electrum.gui.qt.my_treeview import MyTreeView
from electrum.gui.qt.password_dialog import PasswordDialog from electrum.gui.qt.password_dialog import PasswordDialog
from electrum.gui.qt.transaction_dialog import TxDialog from electrum.gui.qt.transaction_dialog import TxDialog
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme, from electrum.gui.qt.util import (
EnterButton, HelpButton, MessageBoxMixin, Buttons,
OkButton, TaskThread, WindowModalDialog, CancelButton,
char_width_in_lineedit, getSaveFileName, ColorScheme,
import_meta_gui, read_QIcon_from_bytes, EnterButton,
read_QPixmap_from_bytes, webopen) HelpButton,
MessageBoxMixin,
OkButton,
TaskThread,
WindowModalDialog,
char_width_in_lineedit,
getOpenFileName,
getSaveFileName,
import_meta_gui,
read_QIcon_from_bytes,
read_QPixmap_from_bytes,
webopen,
)
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
from electrum.payment_identifier import PaymentIdentifier from electrum.payment_identifier import PaymentIdentifier
from electrum.plugin import hook from electrum.plugin import hook
from electrum.transaction import SerializationError, Transaction, tx_from_any from electrum.transaction import SerializationError, Transaction, tx_from_any
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled, from electrum.util import (
decimal_point_to_base_unit_name, read_json_file, DECIMAL_POINT,
write_json_file) FileExportFailed,
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize, FileImportFailed,
Qt, QTimer, pyqtSignal) UserCancelled,
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem, decimal_point_to_base_unit_name,
QStandardItemModel) read_json_file,
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox, write_json_file,
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout, )
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu, from PyQt6.QtCore import (
QMenuBar, QPushButton, QScrollArea, QSizePolicy, QDateTime,
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame, QModelIndex,
QVBoxLayout, QWidget, QDialog) QPersistentModelIndex,
QSize,
Qt,
QTimer,
pyqtSignal,
)
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMenuBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QStyle,
QStyleOptionFrame,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from ...core.heirs import (
HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
BalanceTooLowException,
HeirAmountIsDustException,
Heirs,
WillExecutorFeeTooHighException,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
)
# --- Core (GUI-free) logic layer --- # --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
HeirAmountIsDustException, Heirs)
from ...core.util import Util from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException, from ...core.will import (
HeirNotFoundException, NoHeirsException, AmountException,
NotCompleteWillException, NoWillExecutorNotPresent, HeirChangeException,
TxFeesChangedException, Will, HeirNotFoundException,
WillexecutorChangeException, WillExecutorNotPresent, NoHeirsException,
WillExpiredException, WillItem, WillPostponedException) NotCompleteWillException,
from ...core.willexecutors import Willexecutors NoWillExecutorNotPresent,
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401 TxFeesChangedException,
Will,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillItem,
WillPostponedException,
)
from ...core.willexecutors import ( # noqa: F401
Willexecutors,
is_onion_url,
is_tor_active,
)
# --- Presentation helpers --- # --- Presentation helpers ---
from .theme import server_status_text, server_status_tooltip, status_color from .theme import (
from .window_utils import (bring_to_front, show_modal, show_on_top, server_status_text,
stop_thread, top_level_of) server_status_tooltip,
signature_suffix,
status_color,
)
from .window_utils import (
bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
)
_logger = get_logger(__name__) _logger = get_logger(__name__)
class shown_cv: class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
_type = bool _type = bool
def __init__(self, value): def __init__(self, value):
@@ -105,18 +182,6 @@ def add_widget(grid, label, widget, row, help_):
class CheckAliveError(Exception):
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def log_error(exec_info, window=None): def log_error(exec_info, window=None):
"""Log an error and optionally show it. """Log an error and optionally show it.

File diff suppressed because it is too large Load Diff

View File

@@ -14,12 +14,111 @@ construction) for all business actions, so the heavy logic stays in ``window``
and ``dialogs``. and ``dialogs``.
""" """
from .common import * from typing import TYPE_CHECKING
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget from PyQt6.QtWidgets import QLineEdit as _QLineEdit
from PyQt6.QtWidgets import QMessageBox from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
from .common 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
if TYPE_CHECKING:
from .window import BalWindow
def _can_sign(will_item):
"""True if a will transaction may still be signed (not fully signed)."""
return bool(will_item) and not will_item.get_status("COMPLETE")
def _can_broadcast(will_item):
"""True if a will transaction is ready to broadcast (fully signed)."""
return bool(will_item) and will_item.get_status("COMPLETE")
def _can_delete(will_item):
"""True if a will transaction may be deleted (invalid or unsigned).
Valid AND fully-signed transactions are never deletable: removing them
would silently drop a committed inheritance.
"""
return bool(will_item) and (
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
)
def _apply_select_all(willexecutors, select, valid=None):
"""Apply a bulk selection across a ``{url: we_dict}`` mapping.
``select`` is the target selection value. When ``valid`` is given (a
``{url: bool}`` validity map), the selection is restricted by validity:
selecting sets valid ones to True and invalid ones to False, while
deselecting only clears the invalid ones (valid ones keep their state).
Without ``valid`` every entry is simply set to ``select``. The mapping is
mutated in place and returned.
"""
for url, we in willexecutors.items():
if valid is not None:
if select:
we["selected"] = valid.get(url, False)
elif not valid.get(url, False):
we["selected"] = False
else:
we["selected"] = select
return willexecutors
class HeirListWidget(MyTreeView, MessageBoxMixin): class HeirListWidget(MyTreeView, MessageBoxMixin):
@@ -84,7 +183,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
self.bal_window.new_heir_dialog(edit_key) self.bal_window.new_heir_dialog(edit_key)
def on_edited(self, idx, edit_key, *, text): def on_edited(self, idx, edit_key, *, text):
original = prior_name = self.bal_window.heirs.get(edit_key) prior_name = self.bal_window.heirs.get(edit_key)
if not prior_name: if not prior_name:
return return
col = idx.column() col = idx.column()
@@ -105,12 +204,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
try: try:
self.bal_window.set_heir(prior_name) self.bal_window.set_heir(prior_name)
except Exception: except Exception:
pass self.update()
try:
self.bal_window.set_heir((edit_key,) + original)
except Exception:
self.update()
def delete_heirs(self, selected_keys): def delete_heirs(self, selected_keys):
self.bal_window.delete_heirs(selected_keys) self.bal_window.delete_heirs(selected_keys)
@@ -157,7 +251,17 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
heir = self.bal_window.heirs[key] heir = self.bal_window.heirs[key]
labels = [""] * len(self.Columns) labels = [""] * len(self.Columns)
labels[self.Columns.NAME] = key labels[self.Columns.NAME] = key
labels[self.Columns.ADDRESS] = heir[0] if is_op_return_address(heir[0]):
data_hex = heir[0][len(OP_RETURN_PREFIX):]
try:
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
if len(decoded) > 40:
decoded = decoded[:40] + "\u2026"
labels[self.Columns.ADDRESS] = decoded
except Exception:
labels[self.Columns.ADDRESS] = "OP_RETURN"
else:
labels[self.Columns.ADDRESS] = heir[0]
labels[self.Columns.AMOUNT] = Util.decode_amount( labels[self.Columns.AMOUNT] = Util.decode_amount(
heir[1], self.decimal_point heir[1], self.decimal_point
) )
@@ -184,7 +288,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
set_current = QPersistentModelIndex(idx) set_current = QPersistentModelIndex(idx)
try: try:
self.will_settings_widget.on_locktime_change() self.will_settings_widget.on_locktime_change()
except Exception as e: except Exception:
pass pass
self.set_current_idx(set_current) self.set_current_idx(set_current)
# FIXME refresh loses sort order; so set "default" here: # FIXME refresh loses sort order; so set "default" here:
@@ -204,15 +308,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
menu.addAction(_("Import"), self.bal_window.import_heirs) menu.addAction(_("Import"), self.bal_window.import_heirs)
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs()) menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
newHeirButton = QPushButton(_("New Heir")) new_heir_button = QPushButton(_("New Heir"))
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog) new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
widget = QWidget(self) widget = QWidget(self)
layout = QHBoxLayout(widget) layout = QHBoxLayout(widget)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self) self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
layout.addWidget(self.will_settings_widget) layout.addWidget(self.will_settings_widget)
layout.addWidget(newHeirButton) layout.addWidget(new_heir_button)
toolbar.insertWidget(2, widget) toolbar.insertWidget(2, widget)
@@ -271,7 +375,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.setModel(QStandardItemModel(self)) self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
except Exception as e: except Exception:
pass pass
self.setSortingEnabled(True) self.setSortingEnabled(True)
@@ -297,36 +401,64 @@ class PreviewList(MyTreeView, MessageBoxMixin):
selected_keys.append(sel_key) selected_keys.append(sel_key)
if selected_keys and idx.isValid(): if selected_keys and idx.isValid():
column_title = self.model().horizontalHeaderItem(column).text() column_title = self.model().horizontalHeaderItem(column).text()
# column_data = "\n".join(
# self.model().itemFromIndex(s_idx).text()
# for s_idx in self.selected_in_column(column)
# )
menu.addAction( menu.addAction(
_("details").format(column_title), _("details").format(column_title),
lambda: self.show_transaction(selected_keys), lambda: self.show_transaction(selected_keys),
).setEnabled(len(selected_keys) < 2) ).setEnabled(len(selected_keys) == 1)
menu.addAction(
_("sign").format(column_title),
lambda: self.sign_transactions(selected_keys),
).setEnabled(
any(_can_sign(self.will.get(k)) for k in selected_keys)
)
menu.addAction(
_("broadcast").format(column_title),
lambda: self.broadcast_transactions(selected_keys),
).setEnabled(
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
)
menu.addAction( menu.addAction(
_("check ").format(column_title), _("check ").format(column_title),
lambda: self.check_transactions(selected_keys), lambda: self.check_transactions(selected_keys),
) )
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
try: menu.addSeparator()
self.importaction = self.menu.addAction( menu.addAction(
_("Import"), self.import_will _("copy id").format(column_title),
) lambda: self.copy_txids(selected_keys),
except Exception: )
pass menu.addAction(
_("copy").format(column_title),
lambda: self.copy_tx_hexes(selected_keys),
)
menu.addAction(
_("merge from txn").format(column_title),
lambda: self.merge_from_txn(),
)
menu.addSeparator() menu.addSeparator()
menu.addAction( menu.addAction(
_("delete").format(column_title), lambda: self.delete(selected_keys) _("delete").format(column_title), lambda: self.delete(selected_keys)
).setEnabled(
any(_can_delete(self.will.get(k)) for k in selected_keys)
) )
menu.exec(self.viewport().mapToGlobal(position)) menu.exec(self.viewport().mapToGlobal(position))
def is_deletable(self, key):
"""True if the will transaction ``key`` may be deleted.
Deletion is only allowed for transactions that are NOT valid or NOT
fully signed (invalidated/replaced/mempool/confirmed items, or
unsigned/partially signed ones). Valid and complete transactions are
kept (deleting them would silently drop a committed inheritance).
"""
return _can_delete(self.will.get(key))
def delete(self, selected_keys): def delete(self, selected_keys):
for key in selected_keys: keys = [k for k in selected_keys if self.is_deletable(k)]
for key in keys:
del self.will[key] del self.will[key]
try: try:
del self.bal_window.willitems[key] del self.bal_window.willitems[key]
@@ -338,6 +470,75 @@ class PreviewList(MyTreeView, MessageBoxMixin):
pass pass
self.update() self.update()
def sign_transactions(self, selected_keys):
"""Sign all selected transactions that are not fully signed yet."""
keys = [
k for k in selected_keys
if _can_sign(self.will.get(k))
]
if keys:
self.bal_window.ask_password_and_sign_transactions(
callback=self.update, txids=keys
)
def broadcast_transactions(self, selected_keys):
"""Force-broadcast all selected fully-signed transactions.
``force=True`` makes the will-executors re-accept transactions that
were already pushed before.
"""
keys = [
k for k in selected_keys
if _can_broadcast(self.will.get(k))
]
if keys:
self.bal_window.broadcast_transactions(force=True, txids=keys)
self.update()
def copy_txids(self, selected_keys):
"""Copy the selected transaction IDs to the clipboard (one per line)."""
self.place_text_on_clipboard(
"\n".join(selected_keys), title=_("Transaction IDs")
)
def copy_tx_hexes(self, selected_keys):
"""Copy the selected transactions (serialised hex) to the clipboard."""
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
self.place_text_on_clipboard(hexes, title=_("Transactions"))
def merge_from_txn(self):
"""Merge a transaction read from the clipboard, or from a file if the
clipboard does not contain a valid transaction.
"""
tx = None
try:
tx = tx_from_any(QApplication.clipboard().text())
except Exception:
tx = None
if tx is None:
filename = getOpenFileName(
parent=self.bal_window.window,
title=_("Open transaction file"),
filter="All files (*)",
config=self.bal_window.window.config,
)
if not filename:
return
try:
with open(filename, "r") as f:
data = f.read()
tx = tx_from_any(data)
except Exception as e:
self.bal_window.show_error(
_("Invalid transaction file: {}").format(e)
)
return
try:
self.bal_window.merge_single_transaction(tx)
except Exception as e:
self.bal_window.show_error(str(e))
self.update()
def check_transactions(self, selected_keys): def check_transactions(self, selected_keys):
wout = {} wout = {}
for k in selected_keys: for k in selected_keys:
@@ -385,8 +586,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if bal_tx.we: if bal_tx.we:
we = bal_tx.we["url"] we = bal_tx.we["url"]
labels[self.Columns.WILLEXECUTOR] = we labels[self.Columns.WILLEXECUTOR] = we
status = bal_tx.status status = bal_tx.status + signature_suffix(bal_tx)
if len(bal_tx.status) > 53: if len(status) > 53:
status = "...{}".format(status[-50:]) status = "...{}".format(status[-50:])
labels[self.Columns.STATUS] = status labels[self.Columns.STATUS] = status
# Dedicated, always-readable label describing whether the inheritance # Dedicated, always-readable label describing whether the inheritance
@@ -462,9 +663,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
menu.addAction(_("Prepare"), self.build_transactions) menu.addAction(_("Prepare"), self.build_transactions)
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog) menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions) menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
menu.addAction(_("Export"), self.export_will) export_menu = menu.addMenu(_("Export"))
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get(): export_menu.addAction(_("All"), self.export_will)
self.importaction = menu.addAction(_("Import"), self.import_will) export_menu.addAction(_("Valid"), self.export_will_valid)
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
menu.addAction(_("Import"), self.import_will_into_details)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast) menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check) menu.addAction(_("Check"), self.check)
menu.addAction(_("Invalidate"), self.invalidate_will) menu.addAction(_("Invalidate"), self.invalidate_will)
@@ -536,8 +740,37 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.bal_window.export_will() self.bal_window.export_will()
self.update() self.update()
def import_will(self): def export_will_valid(self):
self.bal_window.import_will() """Export only the will items that are valid."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID")
}
if not subset:
self.show_message(_("No valid will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def export_will_valid_incomplete(self):
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
}
if not subset:
self.show_message(_("No valid, incomplete will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def import_will_into_details(self):
self.bal_window.import_will_into_details()
def merge_will(self):
self.bal_window.merge_will_ui()
def ask_password_and_sign_transactions(self): def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update) self.bal_window.ask_password_and_sign_transactions(callback=self.update)
@@ -756,6 +989,7 @@ class WillExecutorListWidget(MyTreeView):
idx = self.indexAt(position) idx = self.indexAt(position)
column = idx.column() or self.Columns.URL column = idx.column() or self.Columns.URL
selected_keys = [] selected_keys = []
sel_key = None
for s_idx in self.selected_in_column(self.Columns.URL): for s_idx in self.selected_in_column(self.Columns.URL):
item = self.model().itemFromIndex(s_idx) item = self.model().itemFromIndex(s_idx)
# Use the FULL url stored in the key role, NOT item.data(0): the # Use the FULL url stored in the key role, NOT item.data(0): the
@@ -771,6 +1005,15 @@ class WillExecutorListWidget(MyTreeView):
# self.model().itemFromIndex(s_idx).text() # self.model().itemFromIndex(s_idx).text()
# for s_idx in self.selected_in_column(column) # for s_idx in self.selected_in_column(column)
# ) # )
# When exactly ONE cell is selected, offer "Copy" to copy the value
# of that cell to the clipboard (e.g. a single url / address / fee).
# This list has no ``main_window``, so use QApplication directly.
if len(self.selectionModel().selectedIndexes()) == 1:
cell_value = self.model().itemFromIndex(idx).text()
menu.addAction(
_("Copy"),
lambda: QApplication.clipboard().setText(cell_value),
)
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]): if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
menu.addAction( menu.addAction(
_("deselect").format(column_title), _("deselect").format(column_title),
@@ -929,6 +1172,17 @@ class WillExecutorListWidget(MyTreeView):
pass pass
else: else:
items.append(QStandardItem(e)) items.append(QStandardItem(e))
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
grey = QColor("#808080")
for item in items:
font = item.font()
font.setItalic(True)
item.setFont(font)
item.setForeground(grey)
items[self.Columns.SELECTED].setEditable(False) items[self.Columns.SELECTED].setEditable(False)
items[self.Columns.URL].setEditable(True) items[self.Columns.URL].setEditable(True)
items[self.Columns.ADDRESS].setEditable(True) items[self.Columns.ADDRESS].setEditable(True)
@@ -1008,13 +1262,44 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
b.clicked.connect(self.import_file) b.clicked.connect(self.import_file)
buttonbox.addWidget(b) buttonbox.addWidget(b)
b = QPushButton(_("Export")) def _menu_button(label):
b.clicked.connect(self.export_file) btn = QToolButton()
buttonbox.addWidget(b) btn.setText(_(label))
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
buttonbox.addWidget(btn)
return btn
b = QPushButton(_("Ping All")) export_btn = _menu_button("Export")
b.clicked.connect(self.update_willexecutors) export_menu = QMenu(export_btn)
buttonbox.addWidget(b) export_menu.addAction(_("Export all"), lambda: self.export_file())
export_menu.addAction(
_("Export selected"), lambda: self.export_file(subset="selected")
)
export_menu.addAction(
_("Export only valid"), lambda: self.export_file(subset="valid")
)
export_btn.setMenu(export_menu)
ping_btn = _menu_button("Ping All")
ping_menu = QMenu(ping_btn)
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
ping_menu.addAction(
_("Ping selected"), lambda: self.ping_selected_willexecutors()
)
ping_btn.setMenu(ping_menu)
select_btn = _menu_button("Select All")
select_menu = QMenu(select_btn)
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
select_menu.addAction(
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
)
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
select_menu.addAction(
_("Deselect only invalid"),
lambda: self.set_select_all(False, only_valid=True),
)
select_btn.setMenu(select_menu)
vbox.addLayout(buttonbox) vbox.addLayout(buttonbox)
# self.will_executor_list_widget.update() # self.will_executor_list_widget.update()
@@ -1135,6 +1420,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
add_another_btn.clicked.connect(add_another) add_another_btn.clicked.connect(add_another)
else: else:
self._add_another = False self._add_another = False
add_another_btn = None
row = 0 row = 0
grid.addWidget(QLabel(_("URL")), row, 0) grid.addWidget(QLabel(_("URL")), row, 0)
@@ -1227,13 +1513,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
self.bal_window.download_list(self.bal_window.willexecutors, on_success) self.bal_window.download_list(self.bal_window.willexecutors, on_success)
def export_file(self, path): def export_file(self, subset=None):
data = self.export_data(subset)
if subset and not data:
self.show_message(_("No will-executor matches the selected filter"))
return
export_meta_gui( export_meta_gui(
self.bal_window.window, "willexecutors.json", self.export_json_file self.bal_window.window,
"willexecutors.json",
partial(self.export_json_file, subset=subset),
) )
def export_json_file(self, path): def export_data(self, subset=None):
write_json_file(path, self.willexecutors_list) data = self.willexecutors_list
if subset == "selected":
data = {
url: we
for url, we in data.items()
if Willexecutors.is_selected(we)
}
elif subset == "valid":
valid = self._validity()
data = {
url: we for url, we in data.items() if valid.get(url, False)
}
return data
def export_json_file(self, path, subset=None):
write_json_file(path, self.export_data(subset))
def _validity(self):
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self.bal_window.window.wallet.dust_threshold()
return {
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
for url, we in self.willexecutors_list.items()
}
def _selected_willexecutors(self):
return {
url: we
for url, we in self.willexecutors_list.items()
if Willexecutors.is_selected(we)
}
def set_select_all(self, select, only_valid=False):
"""Apply a bulk selection across all will-executors.
``select=True`` selects all (or only the valid ones when
``only_valid=True``, deselecting the invalid ones); ``select=False``
deselects all (or only the invalid ones when ``only_valid=True``,
leaving the valid ones selected).
"""
valid = self._validity() if only_valid else None
_apply_select_all(self.willexecutors_list, select, valid)
self.save_willexecutors()
def ping_selected_willexecutors(self):
wes = self._selected_willexecutors()
if not wes:
self.show_message(_("No will-executor is selected"))
return
self.update_willexecutors(wes)
def import_file(self): def import_file(self):
import_meta_gui( import_meta_gui(

View File

@@ -15,14 +15,39 @@ 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 .common import * from .common import (
from .common import _, _logger # underscore names are not re-exported by "import *" _,
from .common import read_QIcon_from_bytes _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_QPixmap_from_bytes,
show_modal,
webopen,
)
from .dialogs import BalDialog
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
from .window import BalWindow from .window import BalWindow
from .dialogs import BalDialog
from PyQt6.QtWidgets import QLayout
def _window_key(window): def _window_key(window):
@@ -37,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)
@@ -46,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):
@@ -88,9 +117,11 @@ class Plugin(BalPlugin):
except Exception: except Exception:
return [] return []
try: try:
from electrum.gui.qt.plugins_dialog import PluginsDialog from electrum.gui.qt.plugins_dialog import (
PluginsDialog as plugins_dialog, # noqa: N813
)
except Exception: except Exception:
PluginsDialog = None plugins_dialog = None
app = QApplication.instance() app = QApplication.instance()
if app is None: if app is None:
return [] return []
@@ -106,7 +137,7 @@ class Plugin(BalPlugin):
for w in app.topLevelWidgets(): for w in app.topLevelWidgets():
try: try:
is_match = False is_match = False
if PluginsDialog is not None and isinstance(w, PluginsDialog): if plugins_dialog is not None and isinstance(w, plugins_dialog):
is_match = True is_match = True
elif type(w).__name__ == "PluginsDialog": elif type(w).__name__ == "PluginsDialog":
is_match = True is_match = True
@@ -142,17 +173,17 @@ class Plugin(BalPlugin):
each is guarded independently. each is guarded independently.
""" """
try: try:
from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
except Exception: except Exception:
QDialog = None qdialog = None
# 1) reject() / done(): the reliable way to end an exec() modal loop. # 1) reject() / done(): the reliable way to end an exec() modal loop.
if QDialog is not None and isinstance(d, QDialog): if qdialog is not None and isinstance(d, qdialog):
try: try:
d.reject() d.reject()
except Exception as e: except Exception as e:
_logger.debug("reject() failed: {}".format(e)) _logger.debug("reject() failed: {}".format(e))
try: try:
d.done(QDialog.DialogCode.Rejected) d.done(qdialog.DialogCode.Rejected)
except Exception as e: except Exception as e:
_logger.debug("done() failed: {}".format(e)) _logger.debug("done() failed: {}".format(e))
# 2) close(): covers non-QDialog top-levels and is a harmless extra. # 2) close(): covers non-QDialog top-levels and is a harmless extra.
@@ -172,9 +203,9 @@ class Plugin(BalPlugin):
it and closes it themselves (it must not linger in the background). it and closes it themselves (it must not linger in the background).
""" """
try: try:
from PyQt6.QtCore import QTimer from PyQt6.QtCore import QTimer as qtimer # noqa: N813
except Exception: except Exception:
QTimer = None qtimer = None
# Schedule of retry delays (ms) measured from each call. # Schedule of retry delays (ms) measured from each call.
retry_delays = [400, 800, 1500] retry_delays = [400, 800, 1500]
dialogs = Plugin._find_plugins_manager_dialogs() dialogs = Plugin._find_plugins_manager_dialogs()
@@ -190,8 +221,8 @@ class Plugin(BalPlugin):
if not still_open: if not still_open:
_logger.info("plugins dialog closed successfully") _logger.info("plugins dialog closed successfully")
return return
if attempt < len(retry_delays) and QTimer is not None: if attempt < len(retry_delays) and qtimer is not None:
QTimer.singleShot( qtimer.singleShot(
retry_delays[attempt], retry_delays[attempt],
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1), lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
) )
@@ -323,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")
@@ -436,13 +505,31 @@ class Plugin(BalPlugin):
# persisted NUM_REMINDERS config (default 3), with a range of 1..5. # persisted NUM_REMINDERS config (default 3), with a range of 1..5.
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5) heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR # Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
# config (default ON, see plugin_base.py), the SAME config used by the # a single will-executor. If a will-executor charges more, the will
# checkbox inside the "Build your will" wizard's will-executor download # will not be built. Default 500,000 satoshi (0.005 BTC).
# window, so the two stay in sync automatically. When enabled the plugin heir_max_willexecutor_fee = BalSpinBox(
# also builds a will that does not require a will-executor (e.g. it can self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
# be saved on a USB stick and a copy given to the heirs). )
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
# "Rebuild will on wallet close" checkbox. Bound to the persisted
# REBUILD_ON_CLOSE config (default ON). When ticked, closing the wallet
# / quitting Electrum runs the "Build your will" wizard to rebuild and
# re-validate the will. When unticked, the will is only rebuilt when
# the user presses Check/Prepare. Visible to all users (BASIC and
# ADVANCED).
heir_rebuild_on_close = BalCheckBox(self.REBUILD_ON_CLOSE)
# "Rebuild automatically on new transactions" checkbox. Bound to the
# persisted AUTO_REBUILD config (default OFF). When ticked, an incoming
# or outgoing wallet transaction automatically re-runs the same rebuild
# flow the wizard runs at wallet close: the delivery date is
# anticipated by one day (so the new will replaces the previous one
# without an invalidation tx), and an on-chain invalidation is only
# built when the anticipated locktime would fall before the Check Alive
# threshold or the threshold is already in the past. Visible to all
# users (BASIC and ADVANCED).
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
# 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:
@@ -498,8 +585,10 @@ class Plugin(BalPlugin):
lbl_event_description, edit_event_description, help_event_description, lbl_event_description, edit_event_description, help_event_description,
lbl_calendar_app, edit_calendar_app, help_calendar_app, lbl_calendar_app, edit_calendar_app, help_calendar_app,
lbl_auto_sign, heir_auto_sign, help_auto_sign, lbl_auto_sign, heir_auto_sign, help_auto_sign,
lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10, reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_auto_sign): reset_btn_11, reset_btn_12, reset_btn_auto_sign):
w.setVisible(not basic) w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on # Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing # a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -525,6 +614,20 @@ class Plugin(BalPlugin):
edit_calendar_app = BalLineEdit(self.CALENDAR_APP) edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
edit_calendar_app.setMinimumWidth(360) edit_calendar_app.setMinimumWidth(360)
# "Save inheritance transactions in wallet history" checkbox + label
# field (History persistence). When the checkbox is ON, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after each check, each tagged with the label below. The label
# field is disabled while the checkbox is off, so the user cannot set a
# label for a feature that is not active.
def on_save_history_change():
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
heir_save_history = BalCheckBox(self.SAVE_HISTORY, on_click=on_save_history_change)
edit_history_label = BalLineEdit(self.HISTORY_LABEL)
edit_history_label.setMinimumWidth(360)
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
def _make_reset_btn(cfg, widget, kind): def _make_reset_btn(cfg, widget, kind):
"""Return a small ``↺`` button that resets a single setting.""" """Return a small ``↺`` button that resets a single setting."""
btn = QPushButton("\u21ba") btn = QPushButton("\u21ba")
@@ -614,28 +717,21 @@ 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 # Max willexecutor fee: maximum fee (in satoshi) allowed for a single
# "No will-executor TX"). When ON the plugin ALSO builds the backup # will-executor. Visible to all users (BASIC and ADVANCED).
# 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( add_widget(
grid, grid,
"Add transaction without willexecutor", "Max Will-Executor Fee (satoshi)",
heir_no_willexecutor, heir_max_willexecutor_fee,
4, 4,
( (
"Create a will that does not require a Will-executor; it can be " "Maximum fee (in satoshi) allowed to be paid to a single "
"saved, for example, on a USB stick, and a copy can be given to " "will-executor. If a will-executor charges more than this, "
"the heirs." "the will will not be built.\n"
"Default: 500,000 satoshi (0.005 BTC)."
), ),
) )
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 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(
@@ -720,6 +816,37 @@ class Plugin(BalPlugin):
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), 10, 3) grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3)
# Save-in-history toggle and history label: advanced-only rows. The
# label field is disabled while the checkbox is off (see
# on_save_history_change above).
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
help_save_history = HelpButton(
"After each check, save the valid will transactions into the "
"wallet's local history (the History tab), each with a label.\n"
"The label may contain the variable:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_save_history), 11, 0)
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 3)
lbl_history_label = QLabel(_("History label"))
help_history_label = HelpButton(
"Label applied to the will transactions saved into the wallet's "
"local history.\n"
"Variables:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_history_label), 12, 0)
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
grid.addWidget(_hide_if_basic(reset_btn_12), 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
# added to the grid. The old code did the opposite - it added them # added to the grid. The old code did the opposite - it added them
@@ -727,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, 11, 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"
), ),
11, 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 #
@@ -760,11 +929,15 @@ 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.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"),
(self.WELIST_SERVER, edit_welist_server, "line"), (self.WELIST_SERVER, edit_welist_server, "line"),
(self.CALENDAR_APP, edit_calendar_app, "line"), (self.CALENDAR_APP, edit_calendar_app, "line"),
(self.SAVE_HISTORY, heir_save_history, "check"),
(self.HISTORY_LABEL, edit_history_label, "line"),
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
] ]
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.
@@ -785,6 +958,10 @@ class Plugin(BalPlugin):
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 1 if str(cfg.default).lower() == "advanced" else 0
) )
# Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Refresh the open BAL windows so any dependent view (e.g. the # Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are) # editable-dates state is not in this list, but hide filters are)
# reflects the reset values. # reflects the reset values.

View File

@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
return "#e83845" # red - failed to push to will-executor return "#e83845" # red - failed to push to will-executor
elif will_item.get_status("PUSHED"): elif will_item.get_status("PUSHED"):
return "#73f3c8" # teal - pushed to will-executor return "#73f3c8" # teal - pushed to will-executor
elif will_item.get_status("PARTIALLY_SIGNED"):
return "#ffb347" # amber - some signatures present, more needed
elif will_item.get_status("COMPLETE"): elif will_item.get_status("COMPLETE"):
return "#2bc8ed" # blue - signed return "#2bc8ed" # blue - signed
else: else:
return _DEFAULT_COLOR return _DEFAULT_COLOR
def signature_suffix(will_item) -> str:
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
Used by the transaction list and the detail view to show how many of the
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
transaction carrying one signature. Returns ``""`` for signed transactions
or when the required count is unknown (no descriptor available yet).
"""
if will_item.get_status("COMPLETE"):
return ""
required = int(getattr(will_item, "sigs_required", 0) or 0)
added = int(getattr(will_item, "sigs_have", 0) or 0)
if not required:
return ""
return " ({}/{})".format(added, required)
def server_status_text(will_item) -> str: def server_status_text(will_item) -> str:
"""Return a short, human-readable label describing the state of a will """Return a short, human-readable label describing the state of a will
item on the will-executor servers (the online inheritance backup). item on the will-executor servers (the online inheritance backup).

View File

@@ -18,89 +18,66 @@ Contents:
* WillWidget - single will-tx box * WillWidget - single will-tx box
""" """
from .common import * from typing import TYPE_CHECKING
from .common import _, _logger # underscore names are not re-exported by "import *"
from ...core.input_rules import (
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
)
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton from .calendar import BalCalendar, BalCalendarButton
from .common 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:
def compute_reminder_offsets(days, count): from .window import BalWindow
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
@@ -214,39 +191,12 @@ class BalTxFeesWidget(QWidget):
class _LockTimeEditor: class _LockTimeEditor(LockTimeEditor):
min_allowed_value = NLOCKTIME_MIN """Qt-side locktime editor base.
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is platform-dependent).
# see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
this is just the mixin that lets Qt widgets reuse them.
"""
class BalTimeEditWidget(QWidget, _LockTimeEditor): class BalTimeEditWidget(QWidget, _LockTimeEditor):
@@ -567,51 +517,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.isyears = False self.isyears = False
self.time_edit = time_edit self.time_edit = time_edit
@staticmethod
def replace_str(text):
"""Strip the relative-time suffixes (d/y) from the text.
Only days ("d") and years ("y") are supported. The block-height
suffix ("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def checkbdy(self, s, pos, appendix):
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = self.replace_str(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def numbify(self): def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted. # Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always # The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed. # UNIX timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition() pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars])) s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
s = "".join([i for i in text if i in chars]) self.isdays = isdays
self.isdays = False self.isyears = isyears
self.isyears = False
pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y")
if "d" in s:
self.isdays = True
if "y" in s:
self.isyears = True
if self.isdays:
s = self.replace_str(s) + "d"
if self.isyears:
s = self.replace_str(s) + "y"
self.blockSignals(True) self.blockSignals(True)
self.setText(s) self.setText(s)
self.blockSignals(False) self.blockSignals(False)
@@ -690,7 +605,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
return return
try: try:
x = int(x) x = int(x)
except Exception as e: except Exception:
x = QDateTime.currentDateTime().timestamp() x = QDateTime.currentDateTime().timestamp()
finally: finally:
# Use the overflow-safe converter: on Windows datetime.fromtimestamp # Use the overflow-safe converter: on Windows datetime.fromtimestamp
@@ -1068,11 +983,12 @@ class WillSettingsWidget(QWidget):
sees several distinct appointments in their calendar. sees several distinct appointments in their calendar.
The number of events is read from the NUM_REMINDERS setting (default 3, The number of events is read from the NUM_REMINDERS setting (default 3,
capped at 5 by the settings dialog). Their dates are computed with capped at 5 by the settings dialog). Their dates are computed by
``compute_reminder_offsets``: the offsets are spread uniformly across the ``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
check-alive period and the LAST event always falls one day before the across the check-alive period; the LAST event always falls one day
delivery deadline (locktime). If the period is shorter than the before the delivery deadline / locktime). If the period is shorter than
requested number of reminders, at most one event per day is produced. the requested number of reminders, at most one event per day is
produced.
Each event: Each event:
* is placed on ``locktime - offset`` days (its own visible date); * is placed on ``locktime - offset`` days (its own visible date);
@@ -1087,103 +1003,23 @@ class WillSettingsWidget(QWidget):
path the user picks in the save dialog (default name "BAL_will_event.ics" path the user picks in the save dialog (default name "BAL_will_event.ics"
on the Desktop). on the Desktop).
""" """
now = BalCalendar.format_time(datetime.now()) # The .ics content (offsets, escaping, folding, VEVENT layout) is built
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
# locktime = delivery deadline. It is exposed by the date widget as # reminder falls in the future it returns None (ToDo #2) and the user is
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum # warned instead of getting an empty-looking file.
# transaction locktime, so the calendar uses the correct delivery date. ics_content = self._ics_provider()
locktime = self.widgets["locktime"].alarm if not ics_content:
self.bal_window.show_warning(
# BASIC vs ADVANCED reminder strategy. _(
# "No reminders were saved: the delivery date is too "
# In ADVANCED mode the reminders are spread uniformly across the "close (or already passed)"
# check-alive (threshold) period, ending one day before the deadline. )
#
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
# user (it stays at an arbitrary default), so spreading reminders over it
# is meaningless. The owner asked that, in BASIC, the calendar simply
# saves the inheritance delivery date with three fixed reminders: 30 days
# before, 10 days before and 1 day before. We also drop any fixed offset
# that would fall in the past (a reminder before "today" is useless), so
# a short-dated will still gets the reminders that are still in the
# future.
if self.bal_window.bal_plugin.is_basic_mode():
# Whole days from now until the delivery date. Fixed offsets (30, 10,
# 1 day before) are applied by basic_reminder_offsets, which also
# drops any offset that would fall in the past.
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
# ADVANCED: spread reminders over the check-alive period as before.
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminders the user asked for (default 3 if unreadable).
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception:
count = 3
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
offsets = compute_reminder_offsets(days, count)
# Per-event heir details and the shared description/summary templates.
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
for heir in self.bal_window.heirs
)
# BASIC mode: use factory defaults (the hidden settings are ignored).
# ADVANCED mode: use the user-configured values.
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{self.bal_window.bal_plugin.version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
) )
lines.extend([ return
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the # Keep the generated .ics in a temp file; it is copied to the path the
# user picks below. # user picks below.
self.temp_path = BalCalendar.write_temp_ics(ics_content) self.temp_path = write_temp_ics(ics_content)
# Group D / D1b: always ask the user WHERE to save the .ics file (the # Group D / D1b: always ask the user WHERE to save the .ics file (the
# plugin no longer tries to open it with a calendar app). The save # plugin no longer tries to open it with a calendar app). The save
@@ -1236,26 +1072,31 @@ class WillSettingsWidget(QWidget):
def _ics_provider(self): def _ics_provider(self):
"""Return the .ics content for the current locktime/threshold values. """Return the .ics content for the current locktime/threshold values.
Used by :class:`BalCalendarButton` as its content provider. Used by :class:`BalCalendarButton` as its content provider. The whole
document (reminder offsets, escaping, folding, VEVENT layout) is built
by the pure :func:`bal.core.reminders.build_ics_reminders`.
""" """
from datetime import datetime, timedelta
try: try:
locktime = self.widgets["locktime"].alarm locktime = self.widgets["locktime"].alarm
if self.bal_window.bal_plugin.is_basic_mode(): basic_mode = self.bal_window.bal_plugin.is_basic_mode()
days_to_deadline = (locktime - datetime.now()).days if basic_mode:
offsets = basic_reminder_offsets(days_to_deadline) # BASIC mode: use factory defaults (the hidden settings are
# ignored) and the fixed 30/10/1 offsets.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else: else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
threshold = self.widgets["threshold"].alarm threshold = self.widgets["threshold"].alarm
days = (locktime - threshold).days
try: try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception: except Exception:
count = 3 num_reminders = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
heirs_details = "\r\n".join( heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, " f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1263,50 +1104,17 @@ class WillSettingsWidget(QWidget):
for heir in self.bal_window.heirs for heir in self.bal_window.heirs
) )
if self.bal_window.bal_plugin.is_basic_mode(): return build_ics_reminders(
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default locktime=locktime,
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default basic_mode=basic_mode,
else: description=raw_description,
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() summary=raw_summary,
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() wallet_name=str(self.bal_window.wallet),
heirs_details=heirs_details,
event_description = BalCalendar.ical_escape( version=self.bal_window.bal_plugin.version,
f"{raw_description}" num_reminders=num_reminders,
.replace("$wallet_name", str(self.bal_window.wallet)) threshold=threshold,
.replace("$heirs_complete", heirs_details)
) )
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{self.bal_window.bal_plugin.version}",
]
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"
except Exception as e: except Exception as e:
_logger.error(f"failed to generate .ics: {e}") _logger.error(f"failed to generate .ics: {e}")
return None return None
@@ -1349,40 +1157,20 @@ class PercAmountEdit(BTCAmountEdit):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount) super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self): def numbify(self):
# The text sanitisation lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
if text == "!": if text == "!":
self.shortcut.emit() self.shortcut.emit()
return return
pos = self.cursorPosition() pos = self.cursorPosition()
chars = "0123456789%" s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
chars += DECIMAL_POINT
s = "".join([i for i in text if i in chars])
if "%" in s:
self.is_perc = True
s = s.replace("%", "")
else:
self.is_perc = False
if DECIMAL_POINT in s:
p = s.find(DECIMAL_POINT)
s = s.replace(DECIMAL_POINT, "")
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
if self.is_perc:
s += "%"
self.setText(s) self.setText(s)
self.setModified(self.hasFocus()) self.setModified(self.hasFocus())
self.setCursorPosition(pos) self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]: def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try: return parse_perc_amount(text, DECIMAL_POINT)
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
def _get_text_from_amount(self, amount): def _get_text_from_amount(self, amount):
out = super()._get_text_from_amount(amount) out = super()._get_text_from_amount(amount)
@@ -1395,15 +1183,15 @@ class PercAmountEdit(BTCAmountEdit):
if self.base_unit: if self.base_unit:
panel = QStyleOptionFrame() panel = QStyleOptionFrame()
self.initStyleOption(panel) self.initStyleOption(panel)
textRect = self.style().subElementRect( text_rect = self.style().subElementRect(
QStyle.SubElement.SE_LineEditContents, panel, self QStyle.SubElement.SE_LineEditContents, panel, self
) )
textRect.adjust(2, 0, -10, 0) text_rect.adjust(2, 0, -10, 0)
painter = QPainter(self) painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color()) painter.setPen(ColorScheme.GRAY.as_color())
if len(self.text()) == 0: if len(self.text()) == 0:
painter.drawText( painter.drawText(
textRect, text_rect,
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
self.base_unit() + " or perc value", self.base_unit() + " or perc value",
) )
@@ -1482,11 +1270,11 @@ class BalSpinBox(QSpinBox):
class WillWidget(QWidget): class WillWidget(QWidget):
def __init__(self, father=None, parent=None): def __init__(self, father=None, parent=None, will=None):
super().__init__() super().__init__()
vlayout = QVBoxLayout() vlayout = QVBoxLayout()
self.setLayout(vlayout) self.setLayout(vlayout)
self.will = parent.bal_window.willitems self.will = will if will is not None else parent.bal_window.willitems
self._bal_parent = parent self._bal_parent = parent
for w in self.will: for w in self.will:
if ( if (
@@ -1513,7 +1301,10 @@ class WillWidget(QWidget):
willpushbutton = QPushButton(w) willpushbutton = QPushButton(w)
willpushbutton.clicked.connect( willpushbutton.clicked.connect(
partial(self._bal_parent.bal_window.show_transaction, txid=w) partial(
self._bal_parent.bal_window.show_transaction,
tx=self.will[w].tx,
)
) )
detaillayout.addWidget(willpushbutton) detaillayout.addWidget(willpushbutton)
locktime = str(BalTimestamp(self.will[w].tx.locktime)) locktime = str(BalTimestamp(self.will[w].tx.locktime))
@@ -1535,7 +1326,9 @@ class WillWidget(QWidget):
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3) fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)" fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
detaillayout.addWidget(qlabel("Transaction fees:", fees_str)) detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
detaillayout.addWidget(qlabel("Status:", self.will[w].status)) detaillayout.addWidget(
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
)
detaillayout.addWidget(QLabel("")) detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>")) detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs: for heir in self.will[w].heirs:
@@ -1570,6 +1363,6 @@ class WillWidget(QWidget):
detailw.setPalette(pal) detailw.setPalette(pal)
hlayout.addWidget(detailw) hlayout.addWidget(detailw)
hlayout.addWidget(WillWidget(w, parent=parent)) hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +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": ["qt"], "available_for": [
"qt",
"cmdline"
],
"icon": "icons/bal32x32.png" "icon": "icons/bal32x32.png"
} }

View File

@@ -6,11 +6,12 @@
Documentation for the **BAL** opensource Electrum plugin for Bitcoin digital Documentation for the **BAL** opensource Electrum plugin for Bitcoin digital
inheritance. Everything here is plain Markdown + images (and optional styled inheritance. Everything here is plain Markdown + images (and optional styled
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**. HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
**no PDF needed**.
## Contents ## Contents
| Document | Markdown (GitHub) | Styled HTML | | Document | Markdown | Styled HTML |
|---|---|---| |---|---|---|
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) | | **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) | | **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
@@ -23,9 +24,8 @@ HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed
## Viewing the HTML versions ## Viewing the HTML versions
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy - Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
from branch, folder `/docs`), then open GitHub) and open `manual/manual.html`.
`https://<owner>.github.io/<repo>/manual/manual.html`.
- Offline: download the `docs/` folder and open the `.html` files in any browser - Offline: download the `docs/` folder and open the `.html` files in any browser
(the styled manual works fully offline; the inheritanceoptions page loads (the styled manual works fully offline; the inheritanceoptions page loads
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback). Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).

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

@@ -43,6 +43,7 @@ important ones:
|---|---|---| |---|---|---|
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL | | `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** | | `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
| `PARTIALLY_SIGNED` | Only **some** of the required signatures are present | a multisig will after a partial sign (cleared by `COMPLETE`) |
| `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors | | `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors |
| `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) | | `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx | | `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
@@ -65,6 +66,7 @@ Flag transitions enforced by `set_status` (the safety rules baked in the code):
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`. - Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`. - Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`). - Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
- Setting `COMPLETE` → clears `PARTIALLY_SIGNED`.
### How states map to row colour in the list ### How states map to row colour in the list
@@ -83,7 +85,8 @@ wins:
| 7 | `CHECKED` | green | `#8afa6c` | | 7 | `CHECKED` | green | `#8afa6c` |
| 8 | `PUSH_FAIL` | red | `#e83845` | | 8 | `PUSH_FAIL` | red | `#e83845` |
| 9 | `PUSHED` | teal | `#73f3c8` | | 9 | `PUSHED` | teal | `#73f3c8` |
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` | | 10 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
| 11 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` | | — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast* > **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
@@ -354,5 +357,5 @@ that limit.
--- ---
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from *This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
`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

@@ -76,6 +76,22 @@ inheritance cases.
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive, *Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
(3) Fees.* (3) Fees.*
### User type: BASIC / ADVANCED
The plugin has two usage modes, chosen from the plugin settings
(**Tools → Plugins → BAL**, *User Type* selector):
- **BASIC** (default) — hides the advanced controls: the **Delivery Time** is
entered only as a precise **Date** (the relative **RAW** durations and the
Raw/Date selector are hidden), the **Check Alive** field is hidden, and the
postpone-on-open behaviour is disabled.
- **ADVANCED** — reveals the **Raw/Date selector** (relative durations such as
`1y` or `30d`) and the **Check Alive** field, and enables the postpone
behaviour described below. Switching to ADVANCED requires typing the
confirmation phrase **"at My Risk"**.
The rest of this section describes the full (ADVANCED) parameter set.
### 1 — Delivery Time (Locktime) ### 1 — Delivery Time (Locktime)
Indicates the date on which the inheritance of your wallet on the blockchain Indicates the date on which the inheritance of your wallet on the blockchain
@@ -94,6 +110,11 @@ If you choose **Raw**, you can insert various options based on a suffix:
*(i.e. check whether you are still alive, and then postpone the inheritance.)* *(i.e. check whether you are still alive, and then postpone the inheritance.)*
> **NB:** the **Check Alive** parameter is available only in **ADVANCED** mode.
> In **BASIC** (default) it is hidden and the plugin re-evaluates the will
> against "now" every time you open Electrum, so the postpone behaviour
> described here does not apply.
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
the time by which the inheritance will **not** be changed by postponing it. the time by which the inheritance will **not** be changed by postponing it.
@@ -223,6 +244,9 @@ plugin will notify you that you need to update the inheritance.
## RAW settings ## RAW settings
> **NB:** relative (**RAW**) durations are available only in **ADVANCED** mode;
> in **BASIC** the Delivery Time is entered only as a precise date.
If you set, for example, `RAW1d` and it is, say, 5 p.m., the plugin will not If you set, for example, `RAW1d` and it is, say, 5 p.m., the plugin will not
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
roughly estimate the blockchain block number corresponding to that time — so roughly estimate the blockchain block number corresponding to that time — so
@@ -239,7 +263,8 @@ with a tolerance of a few hours.
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
later). For such short intervals the **Check Alive** could create problems, so later). For such short intervals the **Check Alive** could create problems, so
set the Check Alive parameter **in the past** (a date before today) — e.g. a set the Check Alive parameter **in the past** (a date before today) — e.g. a
previous month. previous month. *(The Check Alive only exists in **ADVANCED** mode; in
**BASIC** this is not needed.)*
--- ---
@@ -335,7 +360,7 @@ inheritance:
## WillExecutor service list ## WillExecutor service list
This window opens from the Electrum menu, **Tools → Willexecutor**, and shows This window opens from the Electrum menu, **Tools → WillExecutors**, and shows
the official list of willexecutor servers. the official list of willexecutor servers.
If you want to make changes — such as adding an additional willexecutor server — If you want to make changes — such as adding an additional willexecutor server —
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each willexecutor that is online.
| # | Status | Meaning | Colour | HEX | | # | Status | Meaning | Colour | HEX |
|---|--------|---------|--------|-----| |---|--------|---------|--------|-----|
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` | | 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` | | 2 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
| 3 | **Pushed** | TX sent to willexecutor | Azuregreen | `#73F3C8` | | 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
| 4 | **Checked** | TX actually present in the willexecutor | Bright green | `#8AFA6C` | | 4 | **Pushed** | TX sent to willexecutor | Azuregreen | `#73F3C8` |
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` | | 5 | **Checked** | TX actually present in the willexecutor | Bright green | `#8AFA6C` |
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` | | 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
| 7 | **Failed** | Communication failure with willexecutor | Red | `#E83845` | | 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` | | 8 | **Failed** | Communication failure with willexecutor | Red | `#E83845` |
| 9 | **Replaced** | A backdatedlocktime transaction spends the same input | Violet | `#FF97E9` | | 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
| 10 | **Replaced** | A backdatedlocktime transaction spends the same input | Violet | `#FF97E9` |
| 11 | **Updated** | TX reissued keeping the same locktime and heirs | Light violet | `#B266B2` |
--- ---
@@ -448,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.
--- ---
@@ -500,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**

285
make-release.sh Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# make-release.sh — Create a Gitea release for bal-electrum-plugin
#
# Usage:
# ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
#
# Requires: git, gpg, curl, python3, sha256sum
# Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
set -eo pipefail
# ── helpers ──────────────────────────────────────────────────────────
die() { echo "Error: $*" >&2; exit 1; }
info() { echo ""; echo "── $* ──"; }
# ── 0. Resolve version ──────────────────────────────────────────────
MANIFEST="bal/manifest.json"
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
# read current version from manifest
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
ARG="${1:-}"
if [ -n "$ARG" ]; then
# normalise: accept "v0.6.2" or "0.6.2"
NEW_VER="${ARG#v}"
TAG="v${NEW_VER}"
else
TAG="v${CURRENT_VER}"
NEW_VER=""
fi
echo "=== Release ${TAG} ==="
echo "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
# ── 1. Bump version in manifest (if arg provided) ──────────────────
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
python3 -c "
import json
f = open('${MANIFEST}')
d = json.load(f); f.close()
d['version'] = '${NEW_VER}'
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
print(json.dumps(d, indent=4, ensure_ascii=False))
"
git add "$MANIFEST"
else
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
fi
# ── 2. Clean caches ─────────────────────────────────────────────────
info "[2/10] Cleaning __pycache__ and .pyc"
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find bal -name "*.pyc" -delete 2>/dev/null || true
find bal -name "*.pyo" -delete 2>/dev/null || true
# ── 3. Run tests ────────────────────────────────────────────────────
info "[3/10] Running test suite"
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.py \
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py \
-q 2>&1; then
echo "All tests passed."
else
die "Tests failed — aborting release."
fi
# ── 4. Lint (optional) ─────────────────────────────────────────────
info "[4/10] Lint with ruff"
if command -v ruff &>/dev/null; then
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
| grep -vE "F401|F403|F405|F841" || true)
if [ -n "$RUFF_ERRORS" ]; then
echo "New ruff errors:"
echo "$RUFF_ERRORS"
die "Lint errors found — fix before releasing."
else
echo "Lint clean (ignoring known pre-existing warnings)."
fi
else
echo "ruff not installed — skipping lint."
fi
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
info "[5/10] Building ZIP"
ZIP_NAME="bal_${TAG}.zip"
python3 build_zip.py "$ZIP_NAME"
# ── 6. GPG sign (armor + binary) + export public key ───────────────
info "[6/10] Signing with GPG"
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
ASC_FILE="${ZIP_NAME}.asc"
SIG_FILE="${ZIP_NAME}.sig"
PGP_FILE="svatantrya.asc"
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
echo " Signed: $ASC_FILE"
echo " Signed: $SIG_FILE"
echo " Public key: $PGP_FILE"
# ── 7. SHA-256 checksum ────────────────────────────────────────────
info "[7/10] Computing SHA-256"
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
echo " SHA-256: ${SHA256_HASH}"
# ── 8. Pause for Electrum test ─────────────────────────────────────
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
echo ""
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
echo ""
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
echo ""
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
case "$CONFIRM" in
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
*) die "Aborted by user." ;;
esac
# ── 9. Git tag + push ──────────────────────────────────────────────
info "[9/10] Creating and pushing tag ${TAG}"
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
# credentials
GITEA_USER="${GITEA_USER:-}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
if [ -f ~/.git-credentials ]; then
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
if [ -n "$CREDS_LINE" ]; then
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
GITEA_TOKEN="$GITEA_PASS"
fi
fi
fi
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
API="https://$GITEA_HOST/gitea/api/v1"
# create annotated tag
git tag -d "$TAG" 2>/dev/null || true
git tag -a "$TAG" -m "$TAG" HEAD
# push tag
REMOTE_NAME="gitea-target"
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
git remote rm "$REMOTE_NAME" 2>/dev/null || true
git remote add "$REMOTE_NAME" "$REMOTE_URL"
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
git push "$REMOTE_NAME" "$TAG" --force
# ── 10. Create release + upload assets ──────────────────────────────
info "[10/10] Creating Gitea release"
RELEASE_BODY=$(python3 -c "
import json
sha256 = '${SHA256_HASH}'
zip_name = '${ZIP_NAME}'
asc_name = '${ASC_FILE}'
sig_name = '${SIG_FILE}'
pgp_file = '${PGP_FILE}'
tag = '${TAG}'
body = f'''Release {tag}
## SHA-256 Checksum
\`\`\`
{sha256} {zip_name}
\`\`\`
### Verify SHA-256
\`\`\`bash
sha256sum -c {zip_name}.sha256
\`\`\`
## Download
- \`{zip_name}\` - Plugin BAL {tag}
- \`{asc_name}\` - GPG signature (armor)
- \`{sig_name}\` - GPG signature (binary)
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
## GPG Verification
### Import the signing key
\`\`\`bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
\`\`\`
Or download \`{pgp_file}\` from the assets above:
\`\`\`bash
gpg --import {pgp_file}
\`\`\`
### Verify the signature (armor)
\`\`\`bash
gpg --verify {asc_name} {zip_name}
\`\`\`
### Verify the signature (binary)
\`\`\`bash
gpg --verify {sig_name} {zip_name}
\`\`\`
Expected output:
\`\`\`
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
\`\`\`
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
Public key: https://bitcoin-after.life/svatantrya.asc'''
print(json.dumps({'body': body}, ensure_ascii=False))
")
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "Error creating release: HTTP $HTTP_CODE"
echo "$BODY"
exit 1
fi
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
# upload assets
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
BASENAME=$(basename "$FILE")
echo " Uploading $BASENAME ..."
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
-F "attachment=@${FILE}" -F "name=${BASENAME}")
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
if [ "$UPLOAD_CODE" == "201" ]; then
echo " OK ($BASENAME)"
else
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
fi
done
echo ""
echo "=== Done ==="
echo "Release: $HTML_URL"
echo "Assets:"
echo " ${ZIP_NAME}"
echo " ${ASC_FILE}"
echo " ${SIG_FILE}"
echo " ${ZIP_NAME}.sha256"
echo " ${PGP_FILE}"
echo "SHA-256: ${SHA256_HASH}"

18
pyproject.toml Normal file
View File

@@ -0,0 +1,18 @@
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select =["E", "W", "F", "I", "N", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
"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/window.py" = ["N802"] # getMsg
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
"bal/core/will.py" = ["N818"] # public exception names
"bal/core/willexecutors.py" = ["N818"] # public exception names
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import

30
svatantrya.asc Normal file
View File

@@ -0,0 +1,30 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
=6EE4
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
if isinstance(node.value, str) and hasattr(node, "end_lineno"): if isinstance(node.value, str) and hasattr(node, "end_lineno"):
self.spans.append((node.lineno, node.end_lineno)) self.spans.append((node.lineno, node.end_lineno))
self.generic_visit(node) self.generic_visit(node)
s = _S(); s.visit(tree) s = _S()
s.visit(tree)
drop = set() drop = set()
for a, b in s.spans: for a, b in s.spans:
drop.update(range(a, b + 1)) drop.update(range(a, b + 1))
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
def main(pkg: str) -> int: def main(pkg: str) -> int:
from PyQt6.QtWidgets import QApplication, QDialog, QWidget from PyQt6.QtWidgets import QApplication, QDialog, QWidget
app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
wu = importlib.import_module(pkg + ".gui.qt.window_utils") wu = importlib.import_module(pkg + ".gui.qt.window_utils")
# top_level_of: returns the top-level container of a child widget # top_level_of: returns the top-level container of a child widget
w = QWidget(); child = QWidget(w) w = QWidget()
child = QWidget(w)
assert wu.top_level_of(child) is w assert wu.top_level_of(child) is w
assert wu.top_level_of(None) is None assert wu.top_level_of(None) is None
print("[OK] top_level_of") print("[OK] top_level_of")

11067
tests/karen7

File diff suppressed because one or more lines are too long

View File

@@ -31,7 +31,7 @@ N = 8 # number of servers
def main(): def main():
we_mod = importlib.import_module(f"{PKG}.core.willexecutors") we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
W = we_mod.Willexecutors we_cls = we_mod.Willexecutors
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ---- # ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
def slow_get_info(url, we, **kwargs): def slow_get_info(url, we, **kwargs):
@@ -43,8 +43,8 @@ def main():
we["status"] = 200 we["status"] = 200
return we return we
orig_get_info = W.get_info_task orig_get_info = we_cls.get_info_task
W.get_info_task = staticmethod(slow_get_info) we_cls.get_info_task = staticmethod(slow_get_info)
try: try:
wes = {} wes = {}
for i in range(N): for i in range(N):
@@ -57,7 +57,7 @@ def main():
seen.append((url, ok)) seen.append((url, ok))
start = time.time() start = time.time()
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N) we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
elapsed = time.time() - start elapsed = time.time() - start
# Sequential would take ~ N * SLOW. Parallel must be far less. # Sequential would take ~ N * SLOW. Parallel must be far less.
@@ -81,15 +81,15 @@ def main():
assert we["status"] == "KO", (url, we) assert we["status"] == "KO", (url, we)
print("[OK] ping results written back into the willexecutors mapping") print("[OK] ping results written back into the willexecutors mapping")
finally: finally:
W.get_info_task = orig_get_info we_cls.get_info_task = orig_get_info
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ---- # ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
def slow_push(we, **kwargs): def slow_push(we, **kwargs):
time.sleep(SLOW) time.sleep(SLOW)
return "fail" not in we["url"] return "fail" not in we["url"]
orig_push = W.push_transactions_to_willexecutor orig_push = we_cls.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push) we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
try: try:
wes = {} wes = {}
for i in range(N): for i in range(N):
@@ -106,7 +106,7 @@ def main():
pushed.append((url, ok)) pushed.append((url, ok))
start = time.time() start = time.time()
results = W.push_transactions_parallel(wes, on_each=on_each_push, results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
max_workers=N) max_workers=N)
elapsed = time.time() - start elapsed = time.time() - start
@@ -117,11 +117,11 @@ def main():
f"(sequential would be ~{sequential:.2f}s)") f"(sequential would be ~{sequential:.2f}s)")
assert len(results) == N, results assert len(results) == N, results
for url, (ok, exc) in results.items(): for url, (ok, _exc) in results.items():
assert ok == ("good" in url), (url, ok) assert ok == ("good" in url), (url, ok)
print("[OK] push results correct for every server") print("[OK] push results correct for every server")
finally: finally:
W.push_transactions_to_willexecutor = orig_push we_cls.push_transactions_to_willexecutor = orig_push
# ---- 2b) global deadline: a hung server must not block past `deadline` ---- # ---- 2b) global deadline: a hung server must not block past `deadline` ----
def hanging_push(we, **kwargs): def hanging_push(we, **kwargs):
@@ -129,8 +129,8 @@ def main():
time.sleep(10) time.sleep(10)
return True return True
orig_push2 = W.push_transactions_to_willexecutor orig_push2 = we_cls.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(hanging_push) we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
try: try:
wes = { wes = {
"https://fast.example": { "https://fast.example": {
@@ -146,7 +146,7 @@ def main():
return True return True
time.sleep(10) time.sleep(10)
return True return True
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang) we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
timed_out = [] timed_out = []
@@ -154,7 +154,7 @@ def main():
timed_out.append(url) timed_out.append(url)
start = time.time() start = time.time()
W.push_transactions_parallel( we_cls.push_transactions_parallel(
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
) )
elapsed = time.time() - start elapsed = time.time() - start
@@ -163,7 +163,7 @@ def main():
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, " print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
f"hung server reported via on_timeout") f"hung server reported via on_timeout")
finally: finally:
W.push_transactions_to_willexecutor = orig_push2 we_cls.push_transactions_to_willexecutor = orig_push2
# ---- 2c) on_tick is fired periodically from the CALLING thread ---- # ---- 2c) on_tick is fired periodically from the CALLING thread ----
# The elapsed-time counter is driven by an on_tick callback called from the # The elapsed-time counter is driven by an on_tick callback called from the
@@ -175,8 +175,8 @@ def main():
time.sleep(SLOW * 6) # ~3s, long enough for several ticks time.sleep(SLOW * 6) # ~3s, long enough for several ticks
return True return True
orig_push3 = W.push_transactions_to_willexecutor orig_push3 = we_cls.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push2) we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
try: try:
wes = { wes = {
"https://tick.example": { "https://tick.example": {
@@ -191,7 +191,7 @@ def main():
ticks.append(time.time()) ticks.append(time.time())
tick_threads.add(threading.current_thread()) tick_threads.add(threading.current_thread())
W.push_transactions_parallel( we_cls.push_transactions_parallel(
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5 wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
) )
# ~3s push with 0.5s ticks => at least a few ticks. # ~3s push with 0.5s ticks => at least a few ticks.
@@ -202,7 +202,7 @@ def main():
) )
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread") print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
finally: finally:
W.push_transactions_to_willexecutor = orig_push3 we_cls.push_transactions_to_willexecutor = orig_push3
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ---- # ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
# Pressing "Check" verifies each will-executor still holds its tx. This used # Pressing "Check" verifies each will-executor still holds its tx. This used
@@ -214,8 +214,8 @@ def main():
time.sleep(SLOW) time.sleep(SLOW)
return {"tx": "ok"} if "good" in url else None return {"tx": "ok"} if "good" in url else None
orig_check = W.check_transaction orig_check = we_cls.check_transaction
W.check_transaction = staticmethod(slow_check) we_cls.check_transaction = staticmethod(slow_check)
try: try:
targets = [] targets = []
for i in range(N): for i in range(N):
@@ -228,7 +228,7 @@ def main():
checked.append((wid, res)) checked.append((wid, res))
start = time.time() start = time.time()
results = W.check_transactions_parallel( results = we_cls.check_transactions_parallel(
targets, on_each=on_each_check, max_workers=N targets, on_each=on_each_check, max_workers=N
) )
elapsed = time.time() - start elapsed = time.time() - start
@@ -239,7 +239,7 @@ def main():
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers " print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)") f"(sequential would be ~{sequential:.2f}s)")
finally: finally:
W.check_transaction = orig_check we_cls.check_transaction = orig_check
# 2d-bis) global deadline + on_tick from the calling thread # 2d-bis) global deadline + on_tick from the calling thread
def hanging_check(txid, url, **kwargs): def hanging_check(txid, url, **kwargs):
@@ -248,8 +248,8 @@ def main():
time.sleep(10) time.sleep(10)
return {"tx": "ok"} return {"tx": "ok"}
orig_check2 = W.check_transaction orig_check2 = we_cls.check_transaction
W.check_transaction = staticmethod(hanging_check) we_cls.check_transaction = staticmethod(hanging_check)
try: try:
targets = [ targets = [
("idf", "https://fast.example"), ("idf", "https://fast.example"),
@@ -268,7 +268,7 @@ def main():
tick_threads.add(threading.current_thread()) tick_threads.add(threading.current_thread())
start = time.time() start = time.time()
W.check_transactions_parallel( we_cls.check_transactions_parallel(
targets, max_workers=2, deadline=2.0, targets, max_workers=2, deadline=2.0,
on_timeout=on_timeout_check, on_tick=on_tick_check, on_timeout=on_timeout_check, on_tick=on_tick_check,
tick_interval=0.5, tick_interval=0.5,
@@ -282,7 +282,7 @@ def main():
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick " print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
f"fired {len(ticks)}x from the calling thread") f"fired {len(ticks)}x from the calling thread")
finally: finally:
W.check_transaction = orig_check2 we_cls.check_transaction = orig_check2
# ---- 3) the wizard's loop_push must use the parallel helper ---- # ---- 3) the wizard's loop_push must use the parallel helper ----
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push. # The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.

View File

@@ -17,8 +17,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
# Same colors as BalBuildWillDialog # Same colors as BalBuildWillDialog
COLOR_WARNING = "#cfa808" COLOR_WARNING = "#cfa808"

View File

@@ -17,10 +17,15 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import Qt # noqa: E402 from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
COLOR_OK = "#05ad05" COLOR_OK = "#05ad05"

View File

@@ -21,8 +21,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
COLOR_ERROR = "#ff0000" COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05" COLOR_OK = "#05ad05"

View File

@@ -20,12 +20,17 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402 from PyQt6.QtCore import QSize # noqa: E402
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
QLabel,
)
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402 from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
from PyQt6.QtCore import QSize, Qt # noqa: E402 from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
)
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons", ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
"wizard.png") "wizard.png")

View File

@@ -27,12 +27,19 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
QLineEdit, QSpinBox, QLabel,
)
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtCore import Qt # noqa: E402 from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
def _char_w(): def _char_w():

792
tests/samanta7 Normal file

File diff suppressed because one or more lines are too long

View File

@@ -21,18 +21,21 @@ Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
""" """
import sys
import os
import copy import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import ( from bal.core.will import (
WillItem, Will, HeirNotFoundException,
NotCompleteWillException, HeirNotFoundException, NoHeirsException, NoHeirsException,
TxFeesChangedException, WillExpiredException, NotCompleteWillException,
TxFeesChangedException,
Will,
WillExpiredException,
WillItem,
) )
from bal.core.util import Util
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0. # A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
_VALID_TX_HEX = ( _VALID_TX_HEX = (

View File

@@ -26,30 +26,28 @@ def main():
from PyQt6.QtWidgets import QApplication # noqa from PyQt6.QtWidgets import QApplication # noqa
_app = QApplication.instance() or QApplication([]) _app = QApplication.instance() or QApplication([])
results = {}
# 1) Core modules import (these must be GUI-free). # 1) Core modules import (these must be GUI-free).
bal = imp_core("bal", "core.plugin_base") bal = imp_core("bal", "core.plugin_base")
util = imp_core("util", "core.util") util = imp_core("util", "core.util")
heirs = imp_core("heirs", "core.heirs") heirs = imp_core("heirs", "core.heirs")
will = imp_core("will", "core.will") will = imp_core("will", "core.will")
we = imp_core("willexecutors", "core.willexecutors") _we = imp_core("willexecutors", "core.willexecutors")
# 2) GUI module imports. # 2) GUI module imports.
qt = imp_gui() qt = imp_gui()
# 3) Behaviour checks (pure logic, must be identical across versions). # 3) Behaviour checks (pure logic, must be identical across versions).
BalTimestamp = bal.BalTimestamp bal_timestamp = bal.BalTimestamp
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d" assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y" assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str" assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
Util = util.Util util_cls = util.Util
assert Util.is_perc("50%") is True assert util_cls.is_perc("50%") is True
assert Util.is_perc("100") is False assert util_cls.is_perc("100") is False
assert Util.text_to_hex("BAL") == "42414c" assert util_cls.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL" assert util_cls.hex_to_text("42414c") == "BAL"
assert Util.int_locktime(days=1) == 86400 assert util_cls.int_locktime(days=1) == 86400
# heirs constants must keep the same column layout (very delicate!) # heirs constants must keep the same column layout (very delicate!)
assert heirs.HEIR_ADDRESS == 0 assert heirs.HEIR_ADDRESS == 0

View File

@@ -27,19 +27,19 @@ Run:
tests/test_anticipate_manual_locktime.py -q tests/test_anticipate_manual_locktime.py -q
""" """
import sys
import os
import copy import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402 import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
from bal.core.will import ( # noqa: E402 from bal.core.will import ( # noqa: E402
WillItem,
Will,
NotCompleteWillException, NotCompleteWillException,
Will,
WillExpiredException, WillExpiredException,
WillItem,
) )
# A valid serialized tx (1 input + 1 output, version 2). # A valid serialized tx (1 input + 1 output, version 2).

View File

@@ -22,13 +22,13 @@ whether a fix is needed. Run:
python3 -m pytest tests/test_anticipate_past_locktime.py -q python3 -m pytest tests/test_anticipate_past_locktime.py -q
""" """
import sys
import os import os
import sys
import time import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import Util, LOCKTIME_THRESHOLD from bal.core.util import LOCKTIME_THRESHOLD, Util
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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

@@ -0,0 +1,178 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_core_checkalive.py
"""
import sys
import time
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
# ------------------------------------------------------------------ #
# CheckAliveError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ #
# resolve_date_to_check
# ------------------------------------------------------------------ #
def test_basic_mode_uses_now():
fake_now = 1_800_000_000.0
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
def test_advanced_mode_uses_threshold_absolute():
threshold = time.time() + 5 * 86400
settings = {"threshold": threshold}
result = resolve_date_to_check(False, settings)
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta, timezone
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
result = resolve_date_to_check(False, settings, now=fake_now)
# date_to_check = (locktime, midnight-normalised) - 30 days.
expected = (datetime.fromtimestamp(locktime, tz=timezone.utc)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime():
"""A relative threshold never drifts with the clock: re-resolving it a day
later, with the same fixed absolute locktime, yields the same date."""
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
first = resolve_date_to_check(False, settings, now=fake_now)
# Next day: same stored settings (the fixed delivery), a later clock.
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime():
"""A relative locktime is resolved against 'now' first, then the relative
threshold counts N days back from it (matches the settings widget)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
# Recompute the expected value with the same resolution rules:
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
assert result > fake_now
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
# Without a locktime reference, fall back to the legacy forward resolution.
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"}
# On build day the frozen delivery is authoritative: date_to_check is
# frozen - 30d and NEVER drifts, however much later the clock gets.
first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen
)
assert abs(first - (frozen - 30 * 86400)) < 1
later = resolve_date_to_check(
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
)
assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""Without a built will there is no anchor: keeps the legacy now-based
resolution (a moving target, used only before the first build)."""
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now, tz=timezone.utc))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
# ------------------------------------------------------------------ #
# check_alive_expired
# ------------------------------------------------------------------ #
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core checkalive tests passed")

View File

@@ -9,23 +9,34 @@ Run:
python3 tests/test_core_heirs.py python3 tests/test_core_heirs.py
""" """
import sys
import os import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import ( from bal.core.heirs import (
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, HEIR_ADDRESS,
HEIR_DUST_AMOUNT, TRANSACTION_LABEL, HEIR_AMOUNT,
create_op_return_script, HEIR_DUST_AMOUNT,
HEIR_LOCKTIME,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
TRANSACTION_LABEL,
AliasNotFoundException, AliasNotFoundException,
NotAnAddress, AmountNotValid, LocktimeNotValid, AmountNotValid,
HeirExpiredException, HeirAmountIsDustException,
NoHeirsException, WillExecutorFeeException,
BalanceTooLowException, BalanceTooLowException,
HeirAmountIsDustException,
Heirs, Heirs,
LocktimeNotValid,
NoHeirsException,
NotAnAddress,
WillExecutorFeeException,
create_op_return_script,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Constants # Constants
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -68,7 +79,7 @@ def test_op_return_empty():
def test_op_return_too_big(): def test_op_return_too_big():
try: try:
create_op_return_script("ab" * 81) # 81 bytes > max 80 create_op_return_script("ab" * 81) # 81 bytes > max 80
assert False, "expected ValueError" raise AssertionError("expected ValueError")
except ValueError: except ValueError:
pass pass
@@ -177,13 +188,13 @@ def test_validate_amount():
# Invalid # Invalid
try: try:
Heirs.validate_amount("0.000000001") Heirs.validate_amount("0.000000001")
assert False, "expected AmountNotValid" raise AssertionError("expected AmountNotValid")
except AmountNotValid: except AmountNotValid:
pass pass
try: try:
Heirs.validate_amount("-1") Heirs.validate_amount("-1")
assert False, "expected AmountNotValid" raise AssertionError("expected AmountNotValid")
except AmountNotValid: except AmountNotValid:
pass pass
@@ -207,7 +218,7 @@ def test_validate_locktime_expired():
past = int(time.time()) - 86400 # yesterday past = int(time.time()) - 86400 # yesterday
try: try:
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1) Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
assert False, "expected LocktimeNotValid" raise AssertionError("expected LocktimeNotValid")
except LocktimeNotValid: except LocktimeNotValid:
pass pass
@@ -258,9 +269,99 @@ def test_validate_removes_invalid():
assert "alice" in result or True # may or may not pass address check assert "alice" in result or True # may or may not pass address check
# ------------------------------------------------------------------ #
# OP_RETURN helpers
# ------------------------------------------------------------------ #
def test_op_return_prefix_constant():
assert OP_RETURN_PREFIX == "OP_RETURN:"
def test_is_op_return_address():
assert is_op_return_address("OP_RETURN:48656c6c6f")
assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
assert not is_op_return_address("")
assert not is_op_return_address("OP_RETURN")
assert not is_op_return_address("OP_RETURNX:")
def test_get_op_return_hex():
assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f"
assert get_op_return_hex("bc1q...") is None
assert get_op_return_hex("") is None
def test_validate_op_return_hex_valid():
validate_op_return_hex("48656c6c6f")
def test_validate_op_return_hex_invalid():
try:
validate_op_return_hex("nothex!!")
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
def test_validate_op_return_hex_too_long():
try:
validate_op_return_hex("ab" * 81)
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
def test_validate_op_return_hex_empty():
validate_op_return_hex("")
def test_validate_address_op_return():
addr = "OP_RETURN:48656c6c6f"
result = Heirs.validate_address(addr)
assert result == addr
def test_validate_heir_op_return():
k = "test_op_return"
v = ["OP_RETURN:48656c6c6f", "0", "30d"]
result = Heirs.validate_heir(k, v)
assert result[0] == "OP_RETURN:48656c6c6f"
assert result[1] == "0"
# ------------------------------------------------------------------ #
# Heirs class OP_RETURN integration
# ------------------------------------------------------------------ #
def test_heirs_fixed_percent_skips_op_return():
class FakeWallet:
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 __init__(self):
self.db = self.FakeDB()
self.dust_threshold = lambda: 500
wallet = FakeWallet()
heirs = Heirs(wallet)
heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"]
heirs["normal"] = ["addr1", "10000", "9999999999"]
fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = (
heirs.fixed_percent_lists_amount(0, 500)
)
assert "op_ret" in fixed_h
assert "normal" in fixed_h
assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0
assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000
assert fixed_amt == 10000 # OP_RETURN adds 0
if __name__ == "__main__": if __name__ == "__main__":
for name in sorted(dir()): for name in sorted(dir()):
if name.startswith("test_"): if name.startswith("test_"):
globals()[name]() globals()[name]()
print(f" [OK] {name}") print(f" [OK] {name}")
print(f"[OK] All heirs tests passed") print("[OK] All heirs tests passed")

View File

@@ -8,19 +8,20 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
""" """
import sys
import os import os
import sys
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import ( from bal.core.heirs import (
Heirs, create_op_return_script, reduce_outputs, HEIR_AMOUNT,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, Heirs,
create_op_return_script,
reduce_outputs,
) )
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Heirs db-dependent methods # Heirs db-dependent methods
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -139,6 +140,7 @@ def test_prepare_lists_mixed_dust_continues():
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"], "dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
}) })
raised = False raised = False
result = None
try: try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet) result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException: except HeirAmountIsDustException:
@@ -165,6 +167,7 @@ def test_prepare_lists_multi_locktime_continues():
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"], "late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
}) })
raised = False raised = False
result = None
try: try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet) result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException: except HeirAmountIsDustException:
@@ -188,7 +191,7 @@ def test_validate_address_invalid():
from bal.core.heirs import NotAnAddress from bal.core.heirs import NotAnAddress
try: try:
Heirs.validate_address("bad") Heirs.validate_address("bad")
assert False, "should have raised" raise AssertionError("should have raised")
except NotAnAddress: except NotAnAddress:
pass pass

View File

@@ -0,0 +1,165 @@
"""
Tests for ``bal.core.input_rules`` (pure, GUI-free).
Covers the locktime acceptance bounds, the RAW locktime sanitisation, and the
percentage-or-amount field normalisation that the Qt editors wrap.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_core_input_rules.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.input_rules import ( # noqa: E402 (path insert above)
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
replace_dy_suffixes,
)
# ------------------------------------------------------------------ #
# LockTimeEditor
# ------------------------------------------------------------------ #
def test_locktime_editor_is_acceptable():
assert LockTimeEditor.is_acceptable_locktime(100) is True
assert LockTimeEditor.is_acceptable_locktime(0) is True
assert LockTimeEditor.is_acceptable_locktime(-1) is False
assert LockTimeEditor.is_acceptable_locktime(None) is True
def test_locktime_editor_is_acceptable_string():
assert LockTimeEditor.is_acceptable_locktime("100") is True
assert LockTimeEditor.is_acceptable_locktime("abc") is False
assert LockTimeEditor.is_acceptable_locktime("") is True
def test_locktime_editor_min_max():
assert LockTimeEditor.min_allowed_value >= 0
assert LockTimeEditor.max_allowed_value > LockTimeEditor.min_allowed_value
def test_locktime_editor_get_max_allowed_timestamp():
# Always a valid, fromtimestamp-able value (the Windows 2038 clamp).
ts = LockTimeEditor.get_max_allowed_timestamp()
import datetime
datetime.datetime.fromtimestamp(ts) # must not raise
assert ts <= 2**32 - 1
def test_locktime_editor_subclass_bounds():
class Tight(LockTimeEditor):
min_allowed_value = 1000
max_allowed_value = 2000
assert Tight.is_acceptable_locktime(1500) is True
assert Tight.is_acceptable_locktime(999) is False
assert Tight.is_acceptable_locktime(2001) is False
# ------------------------------------------------------------------ #
# replace_dy_suffixes / RAW locktime sanitisation
# ------------------------------------------------------------------ #
def test_replace_dy_suffixes():
# replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now).
assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix)
assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains
assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_normalize_locktime_raw_empty():
s, isdays, isyears, pos = normalize_locktime_raw_text("", 0)
assert s == ""
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_days():
s, isdays, isyears, pos = normalize_locktime_raw_text("30d", 3)
assert s == "30d"
assert isdays is True
assert isyears is False
def test_normalize_locktime_raw_years():
s, isdays, isyears, pos = normalize_locktime_raw_text("2y", 2)
assert s == "2y"
assert isdays is False
assert isyears is True
def test_normalize_locktime_raw_strips_bad_chars():
s, isdays, isyears, pos = normalize_locktime_raw_text("12a34b56", 8)
assert s == "123456"
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_single_suffix():
# "1y30d" collapses to a single suffix; "d" wins (processed first), so the
# "y" is dropped along with all letters.
s, isdays, isyears, pos = normalize_locktime_raw_text("1y30d", 5)
assert isdays is True
assert isyears is False
assert s == "130d"
# ------------------------------------------------------------------ #
# Percentage-or-amount normalisation
# ------------------------------------------------------------------ #
def test_perc_normalize_percent():
s, is_perc = normalize_perc_amount_text("50%", ".")
assert is_perc is True
assert s == "50%"
def test_perc_normalize_no_percent():
s, is_perc = normalize_perc_amount_text("123", ".")
assert is_perc is False
assert s == "123"
def test_perc_normalize_strips_invalid():
s, is_perc = normalize_perc_amount_text("1a2b3", ".")
assert s == "123"
def test_perc_normalize_decimal_limit():
# At most 8 decimals after the point.
s, is_perc = normalize_perc_amount_text("1.1234567890123", ".")
assert s == "1.12345678"
def test_perc_parse():
from decimal import Decimal
assert parse_perc_amount("50%", ".") == Decimal(50)
assert parse_perc_amount("123.45", ".") == Decimal("123.45")
assert parse_perc_amount("abc", ".") is None
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core input_rules tests passed")

View File

@@ -8,14 +8,15 @@ Run:
python3 tests/test_core_plugin_base.py python3 tests/test_core_plugin_base.py
""" """
import sys
import os import os
import sys
import time import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from datetime import datetime, date, timedelta from datetime import date, datetime, timedelta
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# BalTimestamp # BalTimestamp
@@ -206,7 +207,7 @@ def test_default_will_settings_relative():
def test_default_will_settings(): def test_default_will_settings():
settings = BalPlugin.default_will_settings() settings = BalPlugin.default_will_settings()
assert settings["baltx_fees"] == 100 assert settings["baltx_fees"] == 20
assert "threshold" in settings assert "threshold" in settings
assert "locktime" in settings assert "locktime" in settings
# threshold/locktime should be absolute timestamps # threshold/locktime should be absolute timestamps
@@ -228,7 +229,7 @@ def test_validate_will_settings():
# Note: passing None triggers `will_settings = []` which then fails # Note: passing None triggers `will_settings = []` which then fails
# on .get(). This is a latent bug — test passing a dict directly # on .get(). This is a latent bug — test passing a dict directly
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0}) result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
assert result["baltx_fees"] == 100 assert result["baltx_fees"] == 20
# normal settings unchanged # normal settings unchanged
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000} input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}

View File

@@ -0,0 +1,299 @@
"""
Tests for ``bal.core.reminders`` (pure, GUI-free).
Covers the reminder-offset rules (BASIC + ADVANCED), the RFC-5545 helpers
(format_time, ical_escape, fold_ical_line), write_temp_ics, and the unified
``build_ics_reminders`` builder.
This module imports only ``bal.core``, so it runs in the lint venv (no
Electrum/Qt needed) as well as the runtime venv:
Run:
python3 tests/test_core_reminders.py
"""
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.reminders import ( # noqa: E402 (path insert above)
BASIC_REMINDER_OFFSETS,
basic_reminder_offsets,
build_ics_reminders,
compute_reminder_offsets,
fold_ical_line,
format_time,
ical_escape,
write_temp_ics,
)
# ------------------------------------------------------------------ #
# compute_reminder_offsets - distribution rules
# ------------------------------------------------------------------ #
def test_offsets_long_period_three_reminders():
"""30-day period, 3 reminders: spread out, all before the deadline."""
offsets = compute_reminder_offsets(30, 3)
assert len(offsets) == 3
# All strictly before the deadline (offset >= 1 means "n days before end").
assert all(o >= 1 for o in offsets)
# Sorted earliest-first (largest offset first).
assert offsets == sorted(offsets, reverse=True)
# One reminder near the start, one near the end.
assert max(offsets) == 30
assert min(offsets) == 1
def test_offsets_capped_at_one_per_day():
"""Short period: at most one reminder per available day."""
# 2 days but 3 requested -> only 2 reminders, one per day.
assert compute_reminder_offsets(2, 3) == [2, 1]
# 1 day but 3 requested -> a single reminder, the day before the deadline.
assert compute_reminder_offsets(1, 3) == [1]
def test_offsets_empty_when_no_room():
"""No reminders when there is no day before the deadline."""
assert compute_reminder_offsets(0, 3) == []
assert compute_reminder_offsets(-5, 3) == []
# A non-positive count also yields nothing.
assert compute_reminder_offsets(30, 0) == []
def test_offsets_never_exceed_requested_count():
"""The number of reminders never exceeds the requested count (max 5)."""
offsets = compute_reminder_offsets(100, 5)
assert len(offsets) == 5
assert all(o >= 1 for o in offsets)
# Distinct offsets only (no duplicate alarms on the same day).
assert len(set(offsets)) == len(offsets)
def test_offsets_single_reminder_is_day_before_deadline():
"""A single requested reminder fires one day before the deadline."""
assert compute_reminder_offsets(30, 1) == [1]
# ------------------------------------------------------------------ #
# basic_reminder_offsets - fixed BASIC-mode offsets
# ------------------------------------------------------------------ #
def test_basic_offsets_full_period():
"""A far-away delivery keeps all three fixed offsets (30, 10, 1)."""
assert basic_reminder_offsets(365) == [30, 10, 1]
def test_basic_offsets_truncated_by_horizon():
"""Offsets beyond the delivery horizon are dropped."""
assert basic_reminder_offsets(20) == [10, 1]
assert basic_reminder_offsets(5) == [1]
def test_basic_offsets_empty():
"""No future offsets when the delivery is less than a day away."""
assert basic_reminder_offsets(0) == []
assert basic_reminder_offsets(-10) == []
def test_basic_offsets_always_from_fixed_set():
"""Every returned offset is one of the fixed BASIC offsets."""
for horizon in range(0, 40):
result = basic_reminder_offsets(horizon)
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=tz)
assert format_time(dt) == "20250601T103045Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
assert ical_escape("hello world") == "hello world"
def test_ical_escape_backslash():
assert ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\nline2"
result = ical_escape(text)
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
assert ical_escape("\\;,") == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
assert fold_ical_line("SUMMARY:Test") == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "SUMMARY:" + "a" * 100
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
def test_fold_ical_line_unicode():
line = "SUMMARY:" + "é" * 50
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ #
# build_ics_reminders - unified builder
# ------------------------------------------------------------------ #
_LOCKTIME = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
def _common_kwargs():
return dict(
locktime=_LOCKTIME,
description="BAL will of $wallet_name\r\n$heirs_complete",
summary="BAL - Will execution of $wallet_name",
wallet_name="karen7",
heirs_details=" alice - bc1qalice, bob - bc1qbob",
version="0.6.1",
)
def test_build_ics_advanced_separate_events():
"""ADVANCED: 30-day period -> offsets [30, 16, 1], one VEVENT each."""
content = build_ics_reminders(
basic_mode=False,
num_reminders=3,
now=datetime(2026, 7, 1, 0, 0, 0, tzinfo=timezone.utc),
threshold=datetime(2026, 6, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
assert content.startswith("BEGIN:VCALENDAR")
assert content.endswith("\r\n")
lines = content.split("\r\n")
assert lines.count("BEGIN:VEVENT") == 3
assert lines.count("END:VEVENT") == 3
assert "BEGIN:VALARM" not in lines
# Unique UIDs, numbered summaries, last event one day before the deadline.
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == len(set(uids)) == 3
assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines)
last_dt = format_time(_LOCKTIME - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines
# Template substitution; the CRLF inside the description stays as a line
# break in the folded DESCRIPTION (no literal "\n" escaping in ical_escape).
desc = [ln for ln in lines if ln.startswith("DESCRIPTION:")]
assert desc and "karen7" in desc[0]
assert any(ln.lstrip().startswith("alice") for ln in lines)
def test_build_ics_basic_uses_fixed_offsets():
"""BASIC: fixed offsets (30, 10, 1) filtered to the future, threshold not
required."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
lines = content.split("\r\n")
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == 3
# The first event is the 30-day offset.
assert "bal-karen7-30d" in uids[0]
def test_build_ics_returns_none_when_no_reminders():
"""No future reminder -> None (the aligned behavior), not an empty file."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 7, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is None
def test_build_ics_advanced_requires_threshold():
"""ADVANCED mode without a threshold is a programming error."""
try:
build_ics_reminders(basic_mode=False, **_common_kwargs())
except ValueError:
return
raise AssertionError("expected ValueError for missing threshold in ADVANCED")
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core reminders tests passed")

View File

@@ -9,13 +9,14 @@ Run:
python3 tests/test_core_util.py python3 tests/test_core_util.py
""" """
import sys
import os import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest import pytest # pyright: ignore[reportMissingImports]
from bal.core.util import Util, LOCKTIME_THRESHOLD from bal.core.util import Util
def test_locktime_to_str(): def test_locktime_to_str():
@@ -40,7 +41,7 @@ def test_str_to_locktime():
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid # the block-height suffix "b" was removed (A1): "144b" is no longer a valid
# relative locktime, so it is NOT passed through unchanged. # relative locktime, so it is NOT passed through unchanged.
with pytest.raises(Exception): with pytest.raises(ValueError):
Util.str_to_locktime("144b") Util.str_to_locktime("144b")
# integer string -> int # integer string -> int
@@ -84,6 +85,63 @@ def test_int_locktime():
assert Util.int_locktime() == 0 assert Util.int_locktime() == 0
def test_relative_days():
assert Util._relative_days("30d") == 30
assert Util._relative_days("1y") == 365
assert Util._relative_days("2y") == 730
assert Util._relative_days("30D") == 30
assert Util._relative_days(1700000000) is None
assert Util._relative_days("1700000000") is None
assert Util._relative_days("garbage") is None
def test_resolve_locktime_against_tx_absolute():
"""An absolute current date is returned unchanged (compared vs the tx)."""
frozen = 1817424000
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
def test_resolve_locktime_against_tx_unchanged_relative():
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
(coherent), instead of drifting one day per day away from it."""
frozen = 1817424000 # 2027-08-05 00:00 UTC, i.e. a tx built 2026-08-05 with "1y"
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
assert resolved == frozen
def test_resolve_locktime_against_tx_lengthened():
"""A lengthened relative recipe resolves later than the frozen tx locktime
(this is what the postpone check uses to trigger invalidation)."""
frozen = 1817424000 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
assert resolved == frozen + 365 * 86400
def test_resolve_locktime_against_tx_shortened():
"""A shortened relative recipe resolves earlier than the frozen tx locktime
(this is what the anticipate/rebuild path uses)."""
frozen = 1817424000
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
assert resolved < frozen
def test_resolve_locktime_against_tx_no_relative_anchor():
"""When the built recipe was absolute there is no anchor: falls back to the
legacy forward-from-now resolution (returns a timestamp, no crash)."""
frozen = 1817424000
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
assert isinstance(result, int)
assert result > 1700000000
def test_resolve_locktime_against_tx_zero_tx_locktime():
"""A tx with no usable locktime falls back to now-based resolution."""
result = Util.resolve_locktime_against_tx("1y", "1y", 0)
assert isinstance(result, int)
assert result > 1700000000
def test_encode_decode_amount(): def test_encode_decode_amount():
dp = 8 # typical BTC decimal point dp = 8 # typical BTC decimal point
@@ -261,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)
@@ -293,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"
@@ -347,44 +372,44 @@ def test_in_utxo():
def test_cmp_output(): def test_cmp_output():
class O: class Obj:
def __init__(self, addr, val): def __init__(self, addr, val):
self.address = addr self.address = addr
self.value = val self.value = val
assert Util.cmp_output(O("a", 100), O("a", 100)) is True assert Util.cmp_output(Obj("a", 100), Obj("a", 100)) is True
assert Util.cmp_output(O("a", 100), O("b", 100)) is False assert Util.cmp_output(Obj("a", 100), Obj("b", 100)) is False
assert Util.cmp_output(O("a", 100), O("a", 200)) is False assert Util.cmp_output(Obj("a", 100), Obj("a", 200)) is False
def test_in_output(): def test_in_output():
class O: class Obj:
def __init__(self, addr, val): def __init__(self, addr, val):
self.address = addr self.address = addr
self.value = val self.value = val
outputs = [O("a", 100), O("b", 200)] outputs = [Obj("a", 100), Obj("b", 200)]
assert Util.in_output(O("a", 100), outputs) is True assert Util.in_output(Obj("a", 100), outputs) is True
assert Util.in_output(O("z", 999), outputs) is False assert Util.in_output(Obj("z", 999), outputs) is False
assert Util.in_output(O("a", 100), []) is False assert Util.in_output(Obj("a", 100), []) is False
def test_din_output(): def test_din_output():
class O: class Obj:
def __init__(self, addr, val): def __init__(self, addr, val):
self.address = addr self.address = addr
self.value = val self.value = val
outputs = [O("a", 100), O("b", 200)] outputs = [Obj("a", 100), Obj("b", 200)]
# same amount AND same address # same amount AND same address
same_amt, same_addr = Util.din_output(O("a", 100), outputs) same_amt, same_addr = Util.din_output(Obj("a", 100), outputs)
assert same_amt is True and same_addr is True assert same_amt is True and same_addr is True
# same amount but different address # same amount but different address
same_amt, same_addr = Util.din_output(O("c", 100), outputs) same_amt, same_addr = Util.din_output(Obj("c", 100), outputs)
assert same_amt is True and same_addr is False assert same_amt is True and same_addr is False
# different amount # different amount
same_amt, same_addr = Util.din_output(O("z", 999), outputs) same_amt, same_addr = Util.din_output(Obj("z", 999), outputs)
assert same_amt is False and same_addr is False assert same_amt is False and same_addr is False
@@ -439,6 +464,13 @@ if __name__ == "__main__":
test_str_to_locktime() test_str_to_locktime()
test_parse_locktime_string() test_parse_locktime_string()
test_int_locktime() test_int_locktime()
test_relative_days()
test_resolve_locktime_against_tx_absolute()
test_resolve_locktime_against_tx_unchanged_relative()
test_resolve_locktime_against_tx_lengthened()
test_resolve_locktime_against_tx_shortened()
test_resolve_locktime_against_tx_no_relative_anchor()
test_resolve_locktime_against_tx_zero_tx_locktime()
test_encode_decode_amount() test_encode_decode_amount()
test_is_perc() test_is_perc()
test_cmp_array() test_cmp_array()
@@ -453,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

@@ -8,13 +8,13 @@ Run:
python3 tests/test_core_will.py python3 tests/test_core_will.py
""" """
import sys
import os
import copy import copy
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import WillItem, Will from bal.core.will import Will, WillItem
from bal.core.willexecutors import Willexecutors
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = ( _VALID_TX_HEX = (
@@ -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()
@@ -332,11 +316,19 @@ def test_will_check_tx_height():
def test_exceptions(): def test_exceptions():
from bal.core.will import ( from bal.core.will import (
WillException, WillExpiredException, NotCompleteWillException, AmountException,
HeirChangeException, TxFeesChangedException, HeirNotFoundException, FixedAmountException,
WillexecutorChangeException, NoWillExecutorNotPresent, HeirChangeException,
WillExecutorNotPresent, NoHeirsException, HeirNotFoundException,
AmountException, PercAmountException, FixedAmountException, NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
PercAmountException,
TxFeesChangedException,
WillException,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillPostponedException, WillPostponedException,
) )
@@ -375,4 +367,4 @@ if __name__ == "__main__":
if name.startswith("test_"): if name.startswith("test_"):
globals()[name]() globals()[name]()
print(f" [OK] {name}") print(f" [OK] {name}")
print(f"[OK] All Will tests passed") print("[OK] All Will tests passed")

View File

@@ -8,14 +8,28 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
""" """
import sys
import os import os
from unittest.mock import MagicMock, patch, PropertyMock, call import sys
from binascii import unhexlify
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import crypto
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.bitcoin import public_key_to_p2wpkh
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
Transaction,
TxOutpoint,
)
from bal.core.util import Util
from bal.core.will import Will, WillItem from bal.core.will import Will, WillItem
from electrum.transaction import Transaction
_VALID_TX_HEX = ( _VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
@@ -170,6 +184,8 @@ def test_mempool_status_clears_valid():
def test_check_invalidated_invalidated(): def test_check_invalidated_invalidated():
wallet = MagicMock() wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1 wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
# The funding is really consumed by a broadcast tx -> the will is dead.
wallet.adb.get_spender.return_value = "ab" * 32
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "", "willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}) "time": 0, "change": "", "baltx_fees": 100})
@@ -185,6 +201,9 @@ def test_check_invalidated_invalidated():
def test_check_will(): def test_check_will():
wallet = MagicMock() wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0 wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
# No broadcast tx spends the funding: the missing UTXO is only a local
# (history) artifact, so the will is not invalidated by it.
wallet.adb.get_spender.return_value = None
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "", "willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}) "time": 0, "change": "", "baltx_fees": 100})
@@ -196,6 +215,141 @@ def test_check_will():
assert item.get_status("MEMPOOL") is True assert item.get_status("MEMPOOL") is True
# ------------------------------------------------------------------ #
# WillItem signature counts + PARTIALLY_SIGNED status
# ------------------------------------------------------------------ #
def _multisig_descriptor():
"""Return a 2-of-3 wsh multisig descriptor and its three pubkeys."""
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
return parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs))), pubs
def _make_multisig_ptx(nsigs, locktime=None):
"""A 2-of-3 wsh multisig PartialTransaction carrying ``nsigs`` signatures."""
desc, pubs = _multisig_descriptor()
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
txout = PartialTxOutput.from_address_and_value(addr, 50000)
ptx = PartialTransaction()
if locktime is not None:
ptx.locktime = locktime
ptx.add_inputs([txin])
ptx.add_outputs([txout])
return ptx
def _make_multisig_willitem(nsig):
"""A WillItem wrapping an unsigned 2-of-3 partial tx with ``nsig`` sigs.
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization (PSBT serialization drops the descriptor but keeps
the signatures), mirroring the real load-from-wallet flow.
"""
w = {"tx": _make_multisig_ptx(nsig), "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}
item = WillItem(w, _id="mswill")
item.tx.inputs()[0].script_descriptor = _multisig_descriptor()[0]
return item
def test_willitem_sigs_fields_roundtrip():
item = _make_multisig_willitem(1)
assert item.sigs_required == 0
assert item.sigs_have == 0
item.sigs_required = 2
item.sigs_have = 1
item.set_status("PARTIALLY_SIGNED", True)
d = item.to_dict()
assert d["sigs_required"] == 2
assert d["sigs_have"] == 1
assert d["PARTIALLY_SIGNED"] is True
item2 = WillItem(d, _id="mswill")
assert item2.sigs_required == 2
assert item2.sigs_have == 1
assert item2.get_status("PARTIALLY_SIGNED") is True
def test_willitem_legacy_dict_defaults_sig_fields():
# A will saved before the signature-tracking feature has no sig fields:
# they must default to 0 and the flag to False.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}, _id="legacy")
assert item.sigs_required == 0
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
def test_willitem_partial_signed_keeps_valid():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
assert "Partially Signed" in item.status
def test_willitem_complete_clears_partially_signed():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
assert item.get_status("COMPLETE") is True
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_partial():
item = _make_multisig_willitem(1)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 1
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
def test_check_signatures_unsigned_not_partial():
item = _make_multisig_willitem(0)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 0
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_fully_signed_clears_flag():
item = _make_multisig_willitem(2)
item.set_status("PARTIALLY_SIGNED", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_complete_item_clears_flag():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_single_sig_required():
# A single-signature (P2WPKH) will needs exactly 1 signature: 0 present is
# "New", not "partially signed".
pub = crypto.privkey_to_pubkey(bytes([7] * 32)).hex()
item = _make_multisig_willitem(0)
item.tx.inputs()[0].script_descriptor = parse_descriptor("wpkh({})".format(pub))
Will.check_signatures({"mswill": item})
assert item.sigs_required == 1
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# WillItem.__init__ with wallet # WillItem.__init__ with wallet
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -217,6 +371,472 @@ def test_willitem_init_without_wallet():
assert item is not None assert item is not None
# ------------------------------------------------------------------ #
# Will.save_valid_transactions_to_history (history persistence)
# ------------------------------------------------------------------ #
class FakeTxMinedStatus:
def __init__(self, height):
self._height = height
def height(self):
return self._height
class FakeTxInfo:
def __init__(self, height):
self.tx_mined_status = FakeTxMinedStatus(height)
class FakeWallet:
"""Minimal stand-in for an Electrum wallet used by history persistence."""
def __init__(self, stored_txs=None, spenders=None, heights=None, outputs=None,
addresses=None):
self.adb = FakeADB(
stored_txs or {},
spenders=spenders,
heights=heights,
outputs=outputs,
)
self.db = self.adb.db
self.labels = {}
self.save_db_called = 0
self.addresses = list(addresses or [])
def set_label(self, txid, label):
if label is None:
self.labels.pop(txid, None)
else:
self.labels[txid] = label
def get_all_labels(self):
return dict(self.labels)
def save_db(self):
self.save_db_called += 1
def get_addresses(self):
return self.addresses
def get_label_for_txid(self, txid):
return self.labels.get(txid, "")
def get_tx_info(self, tx):
height = self.adb.heights.get(tx.txid(), TX_HEIGHT_LOCAL)
return FakeTxInfo(height)
def get_utxos(self):
utxos = []
for outs in self.adb.outputs.values():
for utxo in outs.values():
if utxo.spent_height is None:
utxos.append(utxo)
return utxos
class FakeADB:
def __init__(self, stored_txs, spenders=None, heights=None, outputs=None):
self.db = FakeDB(stored_txs)
self.added = []
self.removed = []
self.spenders = dict(spenders or {})
self.heights = dict(heights or {})
self.outputs = dict(outputs or {})
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
self.added.append((tx, allow_unrelated))
return True
def remove_transaction(self, txid):
self.removed.append(txid)
def get_spender(self, outpoint):
txid = self.spenders.get(outpoint)
if txid is None:
return None
height = self.heights.get(txid, TX_HEIGHT_LOCAL)
if height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return None
return txid
def get_tx_height(self, txid):
return FakeTxMinedStatus(self.heights.get(txid, TX_HEIGHT_LOCAL))
def get_addr_outputs(self, addr):
return self.outputs.get(addr, {})
class FakeDB:
def __init__(self, stored_txs):
self.stored = dict(stored_txs)
def get_transaction(self, txid):
return self.stored.get(txid)
def _make_simple_willitem(tx, valid=True, we_url=None):
"""A WillItem wrapping *tx* with an optional VALID status and executor."""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
return WillItem(w, _id="wid")
def _exec_label(template, url):
return template.replace("{willexecutor}", url)
def test_save_incomplete_valid_tx_to_history_adds_and_labels():
# A still-unsigned / partially-signed ("New") partial tx is stored in the
# local history, tagged with the decoded label.
tx = _make_multisig_ptx(1)
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert [t.txid() for t, _ in wallet.adb.added] == [tx.txid()]
txid = tx.txid()
assert wallet.labels[txid] == "BitcoinAfterLife inheritance transaction - https://we.example"
assert wallet.save_db_called == 1
def test_save_skips_complete_and_invalid_items():
# A fully-signed ("Complete") tx must NOT be stored: it is removed from the
# local history instead. Invalid items are never touched.
complete = _make_multisig_willitem(2)
complete.set_status("VALID", True)
invalid = _make_simple_willitem(Transaction(_VALID_TX_HEX), valid=False)
new = _make_simple_willitem(_make_multisig_ptx(1), valid=True)
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"complete": complete, "invalid": invalid, "new": new},
wallet,
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
assert [t.txid() for t, _ in wallet.adb.added] == [new.tx.txid()]
assert len(wallet.labels) == 1
assert wallet.labels[new.tx.txid()] == (
"BitcoinAfterLife inheritance transaction - "
)
def test_save_combines_sigs_when_stored_partial():
# The tx to save is already present in the wallet as an incomplete partial
# PSBT: the signatures are combined into it instead of blindly overwriting.
# (Exercised on raw PartialTransactions; through WillItem a complete tx
# round-trips to a plain Transaction, which overwrites instead - see
# test_save_complete_tx_overwrites_stored_partial.)
our = _make_multisig_ptx(2)
stored_partial = _make_multisig_ptx(1)
wallet = FakeWallet(stored_txs={our.txid(): stored_partial})
Will._add_transaction_to_history(wallet, our, our.txid())
assert len(wallet.adb.added) == 1
saved, _ = wallet.adb.added[0]
# The combine path was taken (the stored partial was re-added, not our tx).
assert saved is stored_partial
assert saved.is_complete()
def test_save_removes_complete_item_from_history():
# A fully-signed item is removed from the local history: its matching
# entry (exact label) is deleted, and it is never re-added.
our = _make_multisig_ptx(2)
txid = our.txid()
label = "BitcoinAfterLife inheritance transaction - https://we.example"
item = _make_simple_willitem(our, valid=True, we_url="https://we.example")
wallet = FakeWallet(stored_txs={txid: our})
wallet.labels[txid] = label
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert wallet.adb.added == []
assert txid in wallet.adb.removed
assert txid not in wallet.labels
def test_save_cleanup_removes_stale_exact_label():
tx = _make_multisig_ptx(1)
txid = tx.txid()
stale_txid = "ab" * 32
other_txid = "cd" * 32
label = "BitcoinAfterLife inheritance transaction - https://we.example"
wallet = FakeWallet()
# Pre-existing wallet labels: one current, one stale (same label), one with
# a different executor URL that must be kept.
wallet.labels[txid] = label
wallet.labels[stale_txid] = label
wallet.labels[other_txid] = "BitcoinAfterLife inheritance transaction - https://other.example"
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert stale_txid in wallet.adb.removed
assert other_txid not in wallet.adb.removed
assert txid not in wallet.adb.removed
# The stale tx's label is dropped with it.
assert stale_txid not in wallet.labels
assert other_txid in wallet.labels
assert wallet.labels[txid] == label
def test_save_no_wallet_or_no_adb_is_noop():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
Will.save_valid_transactions_to_history({"wid": item}, None, "LBL")
Will.save_valid_transactions_to_history({"wid": item}, object(), "LBL")
def test_save_never_raises_on_adb_failure():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
class BoomWallet(FakeWallet):
class BoomADB:
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
raise RuntimeError("boom")
def remove_transaction(self, txid):
raise RuntimeError("boom")
def __init__(self):
self.adb = self.BoomADB()
self.labels = {}
def get_all_labels(self):
return {"stale": "BitcoinAfterLife inheritance transaction - "}
wallet = BoomWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
# No exception propagates; a fresh fake works afterwards.
assert True
def test_check_will_does_not_save_to_history():
# History persistence is no longer triggered from check_will: it runs after
# the will is signed (see the GUI hooks). check_will must not touch it.
with patch.object(Will, "save_valid_transactions_to_history") as save_mock:
Will.check_will({}, [], None, 9999999999)
save_mock.assert_not_called()
def test_is_will_valid_calls_check_will_without_history_label():
with patch.object(Will, "check_will") as cw_mock:
Will.is_will_valid({}, 9999999999, 100, [])
assert len(cw_mock.call_args[0]) == 4
assert cw_mock.call_args[0] == ({}, [], False, 9999999999)
# ------------------------------------------------------------------ #
# Signature absorption + status fixes (local-history will tx)
# ------------------------------------------------------------------ #
def _make_willitem_keyed_by_txid(tx, valid=True, we_url=None):
"""A WillItem whose ``_id`` equals its txid (as in a real built will).
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization, mirroring the real load-from-wallet flow.
"""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
item = WillItem(w, _id=tx.txid())
if isinstance(tx, PartialTransaction) and tx.inputs():
desc = getattr(tx.inputs()[0], "script_descriptor", None)
if desc is not None and isinstance(item.tx, PartialTransaction):
item.tx.inputs()[0].script_descriptor = desc
return item
def _make_local_wallet(tx, stored, funding):
"""A FakeWallet where *tx* is stored locally and consumes *funding*."""
return FakeWallet(
stored_txs={tx.txid(): stored},
spenders={funding: tx.txid()},
heights={tx.txid(): TX_HEIGHT_LOCAL},
)
def test_absorb_history_signatures_merges_and_completes():
# The wallet's stored local copy of the will tx carries more signatures
# than the in-memory item: they are merged in and the item becomes COMPLETE.
item = _make_multisig_willitem(1)
stored = _make_multisig_ptx(2)
assert stored.txid() == item.tx.txid()
wallet = FakeWallet(stored_txs={item._id: stored})
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is True
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
def test_absorb_history_signatures_noop_without_stored_copy():
# Nothing stored in the wallet: the in-memory item is left untouched.
item = _make_multisig_willitem(1)
wallet = FakeWallet()
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is False
assert item.get_status("COMPLETE") is False
def test_check_invalidated_keeps_valid_on_local_spend():
# The funding is missing from the wallet's UTXOs only because the will tx
# itself was saved into the local history: a wallet-local spender must not
# invalidate the will.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is False
assert item.get_status("VALID") is True
def test_check_invalidated_invalidates_on_real_spend():
# The funding is consumed by a broadcast transaction: the will is dead.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
ext_spender = "ab" * 32
wallet = FakeWallet(
spenders={funding: ext_spender},
heights={ext_spender: 100},
)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_search_rai_local_artifact_keeps_valid():
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
assert item.get_status("CONFIRMED") is False
def test_search_rai_confirmed_on_broadcast_spender():
# The will tx is broadcast/confirmed: its own (real) spender marks it
# CONFIRMED, not INVALIDATED.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = FakeWallet(
stored_txs={tx.txid(): tx},
spenders={funding: tx.txid()},
heights={tx.txid(): 100},
)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("CONFIRMED") is True
assert item.get_status("INVALIDATED") is False
def test_check_will_merges_history_sigs_and_stays_valid():
# End-to-end: a will tx stored in the local history gained a signature.
# check_will must absorb it, not invalidate the will for the local spend.
now = 1700000000
locktime = 2000000000
tx = _make_multisig_ptx(1, locktime)
stored = _make_multisig_ptx(2, locktime)
assert stored.txid() == tx.txid()
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, stored, funding)
Will.check_will(will, [], wallet, now)
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
# ------------------------------------------------------------------ #
# Util.get_available_utxos (UTXO-view restoration)
# ------------------------------------------------------------------ #
def _make_utxo(prevout_hex="22", idx=0, value=100000,
spent_txid=None, spent_height=None):
txin = PartialTxInput(
prevout=TxOutpoint(bytes.fromhex(prevout_hex) * 32, idx), script_sig=b""
)
txin._trusted_value_sats = value
txin.spent_txid = spent_txid
txin.spent_height = spent_height
return txin
_HISTORY_TEMPLATE = "BitcoinAfterLife inheritance transaction - {willexecutor}"
_HISTORY_LABEL = _HISTORY_TEMPLATE.replace("{willexecutor}", "https://we.example")
def _wallet_with_local_spend(locktime):
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=TX_HEIGHT_LOCAL)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=locktime)},
heights={spender: TX_HEIGHT_LOCAL},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
return wallet, utxo
def test_get_available_utxos_restores_future_bal_local_spend():
# A later-locktime BAL history tx locally spent the coin: it is restored.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert [u.prevout.to_str() for u in result] == [utxo.prevout.to_str()]
def test_get_available_utxos_does_not_restore_unlabeled_spend():
wallet, utxo = _wallet_with_local_spend(locktime=2000)
wallet.labels["ab" * 32] = "some other label"
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_not_later_locktime():
# The stored spender's locktime equals the will's locktime (same will):
# its spend is NOT ignored.
wallet, utxo = _wallet_with_local_spend(locktime=1000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_confirmed_spend():
# A broadcast (confirmed) spender is never ignored.
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=100)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
heights={spender: 100},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_none_locktime_is_raw_view():
# No reference locktime: the raw wallet.get_utxos() view is returned, so a
# locally-spent coin stays hidden.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, None)
assert result == []
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Main # Main
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,486 @@
"""
Tests for will invalidation (cancellation) in ``bal.core.will``.
Covers:
* Will.invalidate_will() - building the invalidation transaction
* Will.set_invalidate() - marking will items as invalidated (status cascade)
The invalidation ("cancellation") transaction spends the same UTXOs that were
committed to the time-locked will, making the original will transactions
unspendable. This is the mechanism used when:
* The will expires (locktime in the past)
* The owner postpones a signed/sent will to a later date
* The check-alive threshold is passed (dead-man's switch)
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_will_invalidate.py -q
"""
import copy
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
# Patch Transaction.add_info_from_wallet so WillItem can parse the tx hex
# without a live Electrum wallet connection.
from electrum.transaction import Transaction
from bal.core.will import Will, WillItem
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused across multiple test suites.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The prevout string that _VALID_TX_HEX spends (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# Change address for the invalidation output.
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
"""Create a WillItem from _VALID_TX_HEX with a known input value.
The input's ``_trusted_value_sats`` is set so that
``invalidate_will`` can read the balance from it.
"""
heirs = {"alice": ["addr_alice", 5000, "30d"]}
if extra_heirs:
heirs.update(extra_heirs)
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": heirs,
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
})
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
# Set the input value so the balance calculation works.
# Use the name-mangled attribute because tx_from_any creates a
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
# reads __value_sats, not _trusted_value_sats.
item.tx.inputs()[0]._TxInput__value_sats = value_sats
if not valid:
item.set_status("INVALIDATED", True)
return item
def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
"""Create a minimal mock UTXO (wallet-side) matching a will input."""
if prevout_str is None:
prevout_str = _PREVOUT_STR
utxo = MagicMock()
utxo.prevout.to_str.return_value = prevout_str
utxo.is_coinbase_output.return_value = is_coinbase
utxo.block_height = 1
utxo.value_sats.return_value = value_sats
return utxo
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
"""Create a mock wallet with the given UTXOs and change address."""
wallet = MagicMock()
wallet.get_utxos.return_value = utxos
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
wallet.network = MagicMock()
return wallet
def _run_invalidate(will, wallet, fees_per_byte=10, current_height=800000):
"""Run ``Will.invalidate_will`` with mocked Electrum tx building.
Returns ``(result, mock_from_io, mock_out)`` so tests can inspect
the calls to ``PartialTransaction.from_io`` and
``PartialTxOutput.from_address_and_value``.
"""
mock_output = MagicMock()
mock_output.value = 0
mock_output.is_change = False
mock_tx = MagicMock()
mock_tx.txid.return_value = "invalidation_txid"
mock_tx.estimated_size.return_value = 200
with patch("bal.core.will.Util.get_current_height", return_value=current_height), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx) as mock_from_io:
result = Will.invalidate_will(will, wallet, fees_per_byte)
return result, mock_from_io, mock_out
# ================================================================== #
# Will.invalidate_will - building the cancellation transaction
# ================================================================== #
class TestInvalidateWill:
"""Tests for ``Will.invalidate_will()``: the cancellation transaction."""
def test_basic_returns_tx(self):
"""A single valid will item with a matching wallet UTXO produces an
invalidation transaction."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None, "should return a transaction"
def test_basic_rbf_enabled(self):
"""The invalidation tx has RBF (Replace-By-Fee) enabled."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate(will, wallet)
result.set_rbf.assert_called_with(True)
def test_basic_locktime_is_current_height(self):
"""The invalidation tx locktime equals the current block height."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
current_height = 750000
_, mock_from_io, _ = _run_invalidate(will, wallet, current_height=current_height)
# from_io(inputs, outputs, locktime=<height>, version=2)
_, kwargs = mock_from_io.call_args
assert kwargs["locktime"] == current_height
def test_basic_version_2(self):
"""The invalidation tx uses Bitcoin transaction version 2."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, _ = _run_invalidate(will, wallet)
_, kwargs = mock_from_io.call_args
assert kwargs["version"] == 2
def test_basic_output_value_deducts_fee(self):
"""The invalidation output value is balance minus fee.
Fee = estimated_size * fees_per_byte.
"""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
fees_per_byte = 10
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=fees_per_byte)
# The second call to from_address_and_value uses balance - fee.
# estimated_size returns 200, so fee = 200 * 10 = 2000.
# Expected output value = 1000000 - 2000 = 998000.
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_basic_spends_correct_utxos(self):
"""The invalidation tx spends the same UTXOs as the will."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, _ = _run_invalidate(will, wallet)
# First positional arg is the list of UTXOs to spend.
spent_utxos = mock_from_io.call_args[0][0]
assert len(spent_utxos) == 1
assert spent_utxos[0].prevout.to_str() == _PREVOUT_STR
def test_no_matching_utxos_returns_none(self):
"""When wallet UTXOs don't match any will inputs, returns None."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(prevout_str="aaaa:1")])
result, _, _ = _run_invalidate(will, wallet)
assert result is None
def test_no_valid_items_returns_none(self):
"""When all will items are INVALIDATED, returns None."""
item = _make_willitem(valid=False)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate(will, wallet)
assert result is None
def test_empty_will_returns_none(self):
"""An empty will dictionary returns None."""
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate({}, wallet)
assert result is None
def test_skips_young_coinbase(self):
"""Coinbase UTXOs younger than current_height + 100 are skipped."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
# Coinbase UTXO: block_height = 800050, current_height = 800000
# 800050 < 800000 + 100 => skipped
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
utxo.block_height = 800050
wallet = _mock_wallet([utxo])
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
assert result is None
def test_includes_mature_coinbase(self):
"""Coinbase UTXOs at or above current_height + 100 are included."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
utxo.block_height = 800150 # >= 800000 + 100
wallet = _mock_wallet([utxo])
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
assert result is not None
def test_fee_exceeds_balance_returns_none(self):
"""When the fee exceeds the balance, returns None.
estimated_size (200) * fees_per_byte (100) = 20000 > balance (100).
"""
item = _make_willitem(value_sats=100)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
assert result is None
# from_io is still called once (for fee estimation), but the
# result is discarded because balance - fee <= 0.
assert mock_from_io.call_count == 1
def test_only_valid_items_contribute_balance(self):
"""INVALIDATED will items are excluded from the balance."""
valid_item = _make_willitem(value_sats=1000000, valid=True)
invalid_item = _make_willitem(value_sats=2000000, valid=False)
will = {"valid": valid_item, "invalid": invalid_item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# Balance = 1000000 (valid only), fee = 200 * 10 = 2000
# Output value = 998000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_first_from_io_uses_full_balance(self):
"""The first from_io call uses the full balance (before fee deduction)
to estimate the fee."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# First from_address_and_value call: value = balance (1000000)
first_call_value = mock_out.call_args_list[0][0][1]
assert first_call_value == 1000000
def test_output_address_is_change_address(self):
"""The invalidation output goes to the wallet's change address."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, _, mock_out = _run_invalidate(will, wallet)
# Both calls to from_address_and_value use the change address.
for call in mock_out.call_args_list:
assert call[0][0] == _CHANGE_ADDR
def test_multiple_utxos_all_matched(self):
"""Multiple matching UTXOs are all included in the invalidation."""
item1 = _make_willitem(value_sats=500000)
item2 = _make_willitem(value_sats=300000)
will = {"tx1": item1, "tx2": item2}
# Two UTXOs with different prevouts matching the two will items.
# Since both items use the same _VALID_TX_HEX, their prevout is the
# same. To test multiple UTXOs, we need a second tx hex with a
# different input.
#
# However, get_all_inputs deduplicates by prevout_str, so even with
# two items sharing the same prevout, only one entry is added to
# prevout_to_spend. The first matching UTXO is what matters.
utxos = [_make_utxo()]
wallet = _mock_wallet(utxos)
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
# Only 1 UTXO spent (deduplication of shared prevout)
spent_utxos = mock_from_io.call_args[0][0]
assert len(spent_utxos) == 1
def test_zero_fees_per_byte(self):
"""With zero fee rate, the full balance goes to the output."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=0)
assert mock_from_io.call_count == 2 # two calls (both succeed)
# Output value = balance - 0 = 1000000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 1000000
# ================================================================== #
# Will.set_invalidate - status flag cascade
# ================================================================== #
class TestSetInvalidate:
"""Tests for ``Will.set_invalidate()``: marking will items as invalidated."""
def test_single_item_no_children(self):
"""Invalidating a single will item sets INVALIDATED and clears VALID."""
item = _make_willitem(valid=True)
item.children = {}
will = {"willid1": item}
Will.set_invalidate("willid1", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_cascades_to_direct_children(self):
"""Invalidating a parent cascades INVALIDATED to its children."""
parent = _make_willitem(valid=True)
child = _make_willitem(valid=True)
parent.children = {"child_id": ["child_id", 0, 0]}
child.children = {}
will = {"parent_id": parent, "child_id": child}
Will.set_invalidate("parent_id", will)
assert parent.get_status("INVALIDATED") is True
assert parent.get_status("VALID") is False
assert child.get_status("INVALIDATED") is True
assert child.get_status("VALID") is False
def test_cascades_to_grandchildren(self):
"""Invalidating cascades through multiple levels of descendants."""
root = _make_willitem(valid=True)
branch = _make_willitem(valid=True)
leaf = _make_willitem(valid=True)
root.children = {"branch_id": ["branch_id", 0, 0]}
branch.children = {"leaf_id": ["leaf_id", 0, 0]}
leaf.children = {}
will = {
"root_id": root,
"branch_id": branch,
"leaf_id": leaf,
}
Will.set_invalidate("root_id", will)
for name, item in [("root", root), ("branch", branch), ("leaf", leaf)]:
assert item.get_status("INVALIDATED") is True, f"{name} should be INVALIDATED"
assert item.get_status("VALID") is False, f"{name} should not be VALID"
def test_empty_children_dict(self):
"""A will item with an empty children dict is a leaf (no cascade)."""
item = _make_willitem(valid=True)
item.children = {}
will = {"wid": item}
Will.set_invalidate("wid", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_does_not_affect_siblings(self):
"""Invalidating one item does not affect unrelated siblings."""
item_a = _make_willitem(valid=True)
item_b = _make_willitem(valid=True)
item_a.children = {}
item_b.children = {}
will = {"a": item_a, "b": item_b}
Will.set_invalidate("a", will)
assert item_a.get_status("INVALIDATED") is True
assert item_a.get_status("VALID") is False
assert item_b.get_status("INVALIDATED") is False
assert item_b.get_status("VALID") is True
def test_multiple_children(self):
"""Invalidating a parent with multiple children cascades to all of them."""
parent = _make_willitem(valid=True)
child1 = _make_willitem(valid=True)
child2 = _make_willitem(valid=True)
parent.children = {
"c1": ["c1", 0, 0],
"c2": ["c2", 0, 0],
}
child1.children = {}
child2.children = {}
will = {"p": parent, "c1": child1, "c2": child2}
Will.set_invalidate("p", will)
assert parent.get_status("INVALIDATED") is True
assert child1.get_status("INVALIDATED") is True
assert child2.get_status("INVALIDATED") is True
def test_idempotent(self):
"""Setting INVALIDATED twice on the same item is a safe no-op."""
item = _make_willitem(valid=True)
item.children = {}
will = {"wid": item}
Will.set_invalidate("wid", will)
Will.set_invalidate("wid", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation tests passed")

View File

@@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -65,6 +64,47 @@ def test_editable_dates_can_be_enabled():
assert BalConfig(cfg, "bal_editable_dates", False).get() is True assert BalConfig(cfg, "bal_editable_dates", False).get() is True
# ------------------------------------------------------------------ #
# History persistence settings (SAVE_HISTORY / HISTORY_LABEL)
# ------------------------------------------------------------------ #
def test_save_history_defaults_on():
"""History persistence is opt-out: the flag defaults to ON."""
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
assert save_history.get() is True
def test_save_history_can_be_disabled_and_read_back():
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
save_history.set(False)
assert BalConfig(cfg, "bal_save_history", True).get() is False
def test_history_label_default_template():
"""The default label contains the {willexecutor} variable."""
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert "{willexecutor}" in history_label.get()
def test_history_label_can_be_changed_and_read_back():
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
history_label.set("My custom label for {willexecutor}")
assert BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
).get() == "My custom label for {willexecutor}"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# C4b - Reset to defaults # C4b - Reset to defaults
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -83,9 +123,10 @@ def _reset_to_defaults(configs):
def test_reset_restores_all_dialog_settings(): def test_reset_restores_all_dialog_settings():
"""C4b: Reset restores every dialog setting to its factory default. """C4b: Reset restores every dialog setting to its factory default.
The dialog exposes seven settings: the original six plus the Group C The dialog exposes nine settings: the original six, the Group C
"Editable dates" checkbox, which the Reset button must also restore (this "Editable dates" checkbox, and the Group H "Save inheritance transactions
was a follow-up fix after the first test round). in history" checkbox + "History label" field, which the Reset button must
also restore.
""" """
cfg = FakeConfig() cfg = FakeConfig()
@@ -103,6 +144,10 @@ def test_reset_restores_all_dialog_settings():
"bal_event_description", "bal_event_description",
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
) )
save_history = BalConfig(cfg, "bal_save_history", True)
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
settings = [ settings = [
hide_replaced, hide_replaced,
hide_invalidated, hide_invalidated,
@@ -111,6 +156,8 @@ def test_reset_restores_all_dialog_settings():
calendar_app, calendar_app,
event_summary, event_summary,
event_description, event_description,
save_history,
history_label,
] ]
# Mutate every setting away from its default. # Mutate every setting away from its default.
@@ -121,11 +168,15 @@ def test_reset_restores_all_dialog_settings():
calendar_app.set("/custom/app") calendar_app.set("/custom/app")
event_summary.set("custom summary") event_summary.set("custom summary")
event_description.set("custom description") event_description.set("custom description")
save_history.set(False)
history_label.set("custom label")
# Sanity: the values really changed. # Sanity: the values really changed.
assert hide_replaced.get() is False assert hide_replaced.get() is False
assert editable_dates.get() is True assert editable_dates.get() is True
assert calendar_app.get() == "/custom/app" assert calendar_app.get() == "/custom/app"
assert save_history.get() is False
assert history_label.get() == "custom label"
# Reset and verify each one is back to its declared default. # Reset and verify each one is back to its declared default.
_reset_to_defaults(settings) _reset_to_defaults(settings)
@@ -133,6 +184,11 @@ def test_reset_restores_all_dialog_settings():
assert s.get() == s.default assert s.get() == s.default
# In particular the "Editable dates" flag is back OFF. # In particular the "Editable dates" flag is back OFF.
assert editable_dates.get() is False assert editable_dates.get() is False
# And the history persistence flag is back ON with the default template.
assert save_history.get() is True
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
def test_reset_does_not_touch_unrelated_settings(): def test_reset_does_not_touch_unrelated_settings():

View File

@@ -10,13 +10,12 @@ Covered behaviour:
most one reminder per available day, and never returns more reminders than most one reminder per available day, and never returns more reminders than
requested. requested.
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports ``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like so these tests run without Qt (or Electrum) at all.
the other GUI tests.
Run: Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ source "$BAL_HOME/electrum/env/bin/activate"
python3 -m pytest tests/test_group_d_alarms.py -q python3 tests/test_group_d_alarms.py
""" """
import os import os
@@ -25,8 +24,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
from bal.gui.qt.widgets import compute_reminder_offsets from bal.core.reminders import compute_reminder_offsets
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks

View File

@@ -0,0 +1,397 @@
"""
Group E - karen7 wallet: build the inheritance then generate the
cancellation (invalidation) transaction.
This test exercises the full pipeline with REAL Electrum transaction
building (no mocking of from_io, from_address_and_value, or is_address):
1. Load the karen7 regtest wallet (heirs + UTXOs).
2. Set Electrum to regtest mode so bcrt1q addresses validate.
3. Build the inheritance transactions via ``Heirs.buildTransactions``
using real ``PartialTransaction.from_io`` and real
``PartialTxOutput.from_address_and_value``.
4. Wrap each built transaction into a ``WillItem`` with VALID status.
5. Populate ``_trusted_value_sats`` on each input (what
``add_info_from_wallet`` does in the real flow).
6. Call ``Will.invalidate_will()`` to generate the cancellation tx.
7. Assert that the cancellation tx is well-formed.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
"""
import copy
import json
import os
import sys
import pytest # pyright: ignore[reportMissingImports]
# ------------------------------------------------------------------ #
# Electrum regtest mode (replaces mocking bitcoin.is_address)
# ------------------------------------------------------------------ #
from electrum import constants
constants.net = constants.BitcoinRegtest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import bitcoin
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
TxOutpoint,
)
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.will import Will, WillItem
# ------------------------------------------------------------------ #
# Load karen7 wallet data
# ------------------------------------------------------------------ #
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
with open(_WALLET_PATH) as _f:
_KAREN7_DATA = json.load(_f)
# ------------------------------------------------------------------ #
# Minimal real implementations (no MagicMock)
# ------------------------------------------------------------------ #
class _Karen7Wallet:
"""Minimal wallet implementation for tests.
Provides only the methods that ``buildTransactions`` and
``invalidate_will`` call. ``network`` is ``None`` so
``Util.get_current_height`` returns 0 without network access.
"""
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
def __init__(self, utxos):
self._utxos = utxos
self.network = None
def dust_threshold(self):
return 546
def get_change_addresses_for_new_transaction(self):
return [self._CHANGE_ADDR]
def get_utxos(self):
return self._utxos
class _Karen7BalPlugin:
"""Minimal bal_plugin config for tests.
Provides only the config accessors that ``buildTransactions`` reads.
No will-executors (``NO_WILLEXECUTOR = True``).
"""
class _NoWillexecutor:
def get(self, *a, **kw):
return True
class _MaxFee:
def get(self, *a, **kw):
return 500000
class _EmptyWelist:
default = {}
def get(self, *a, **kw):
return {"regtest": {}}
NO_WILLEXECUTOR = _NoWillexecutor()
MAX_WILLEXECUTOR_FEE = _MaxFee()
WILLEXECUTORS = _EmptyWelist()
def get_decimal_point(self):
return 8
# ------------------------------------------------------------------ #
# UTXO builder from karen7 data (real PartialTxInput objects)
# ------------------------------------------------------------------ #
def _build_real_utxos(data):
"""Build real ``PartialTxInput`` objects from the karen7 wallet JSON.
Each UTXO gets a proper ``scriptpubkey`` so that ``is_segwit()``
returns ``True`` and the resulting ``PartialTransaction`` can
compute a real ``txid()``.
"""
utxos = []
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
prevout = TxOutpoint(
txid=bfh(txid), out_idx=int(idx)
)
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
txin._TxInput__address = addr
txin._TxInput__scriptpubkey = bitcoin.address_to_script(
addr
)
txin.is_mine = True
utxos.append(txin)
return utxos
# ------------------------------------------------------------------ #
# Build karen7 UTXO value lookup (for populating tx inputs)
# ------------------------------------------------------------------ #
def _build_utxo_value_map(data):
"""Return ``{prevout_str: value_sats}`` from karen7 wallet data."""
m = {}
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for _, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
m[f"{txid}:{idx}"] = value
return m
# ------------------------------------------------------------------ #
# Populate _trusted_value_sats on WillItem tx inputs
# ------------------------------------------------------------------ #
def _populate_input_values(will, utxo_value_map):
"""Set ``_trusted_value_sats`` on every input of every will tx.
This is the equivalent of what ``add_info_from_wallet`` does in the
real flow: looking up the UTXO value and attaching it to the input.
"""
for _, wi in will.items():
for txin in wi.tx.inputs():
prevout_str = txin.prevout.to_str()
if txin._trusted_value_sats is None and prevout_str in utxo_value_map:
txin._trusted_value_sats = utxo_value_map[prevout_str]
# ------------------------------------------------------------------ #
# Inheritance builder (real Electrum, no mocking)
# ------------------------------------------------------------------ #
def _build_inheritance(utxos):
"""Build the inheritance transactions from karen7's heirs and UTXOs.
Returns ``(txs, heirs_model)`` where ``txs`` is a dict of real
``PartialTransaction`` objects produced by ``Heirs.buildTransactions``.
"""
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
wallet = _Karen7Wallet(utxos)
bal_plugin = _Karen7BalPlugin()
txs = h.buildTransactions(bal_plugin, wallet, tx_fees=1, utxos=utxos)
return txs or {}, h
def _txs_to_will(txs, heirs_data):
"""Convert built transactions into a ``{txid: WillItem}`` will dict
with VALID status, using karen7's heir data."""
will = {}
for txid, tx in txs.items():
item_dict = {
"tx": tx,
"heirs": copy.deepcopy(heirs_data),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 1,
}
wi = WillItem(item_dict, _id=txid)
will[txid] = wi
return will
# ================================================================== #
# Build and invalidate tests
# ================================================================== #
class TestKaren7BuildAndInvalidate:
"""Load the real karen7 regtest wallet, build the inheritance
transactions with real Electrum, then generate the cancellation
(invalidation) transaction."""
@pytest.fixture(autouse=True)
def _setup(self):
"""Shared setup: build UTXOs, inheritance, and will once."""
self.utxos = _build_real_utxos(_KAREN7_DATA)
self.utxo_value_map = _build_utxo_value_map(_KAREN7_DATA)
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
self.heirs_data = _KAREN7_DATA["heirs"]
self.txs, self.heirs_model = _build_inheritance(self.utxos)
self.wallet = _Karen7Wallet(self.utxos)
self.will = _txs_to_will(self.txs, self.heirs_data)
_populate_input_values(self.will, self.utxo_value_map)
# ------------------------------------------------------------------ #
# Build tests
# ------------------------------------------------------------------ #
def test_build_produces_real_partial_transactions(self):
"""Building the inheritance produces real PartialTransaction objects."""
assert self.txs, "buildTransactions returned empty"
for txid, tx in self.txs.items():
assert isinstance(tx, PartialTransaction), (
f"tx {txid} should be a real PartialTransaction, "
f"got {type(tx).__name__}"
)
def test_built_txs_have_valid_txid(self):
"""Every built transaction has a computable txid (not None)."""
assert self.txs, "no transactions built"
for txid, tx in self.txs.items():
computed = tx.txid()
assert computed is not None, (
f"tx {txid} has txid() == None"
)
assert computed == txid, (
f"txid mismatch: key={txid}, computed={computed}"
)
def test_built_tx_has_karen7_heirs(self):
"""The built will contains karen7's six heirs."""
assert len(self.heirs_model) == 6
assert list(self.heirs_model.keys()) == [
"aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"
]
def test_will_items_are_valid(self):
"""Every WillItem in the will starts with VALID=True."""
assert self.will, "will is empty"
for wid, wi in self.will.items():
assert wi.get_status("VALID") is True, (
f"WillItem {wid} should be VALID"
)
def test_will_inputs_have_values(self):
"""After populating, every tx input has a non-None value_sats."""
for wid, wi in self.will.items():
for i, txin in enumerate(wi.tx.inputs()):
assert txin.value_sats() is not None, (
f"WillItem {wid} input {i} "
f"({txin.prevout.to_str()}) has no value"
)
# ------------------------------------------------------------------ #
# Invalidation tests
# ------------------------------------------------------------------ #
def test_invalidate_returns_real_tx(self):
"""Calling invalidate_will produces a real PartialTransaction."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None, "invalidate_will returned None"
assert isinstance(result, PartialTransaction), (
f"expected PartialTransaction, got {type(result).__name__}"
)
def test_invalidation_tx_has_rbf(self):
"""The cancellation tx has RBF enabled."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.is_rbf_enabled() is True
def test_invalidation_tx_locktime(self):
"""The cancellation tx locktime equals the current height.
With ``network=None`` the current height is 0.
"""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.locktime == 0
def test_invalidation_tx_version_2(self):
"""The cancellation tx uses Bitcoin transaction version 2."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.version == 2
def test_invalidation_spends_correct_utxos(self):
"""The cancellation tx spends the same UTXOs as the will."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
will_prevouts = set()
for wi in self.will.values():
for txin in wi.tx.inputs():
will_prevouts.add(txin.prevout.to_str())
for txin in result.inputs():
assert txin.prevout.to_str() in will_prevouts, (
f"inval input {txin.prevout.to_str()} not in will UTXOs"
)
def test_invalidation_output_to_change_address(self):
"""The cancellation output goes to the wallet's change address."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
outputs = result.outputs()
assert len(outputs) == 1
assert outputs[0].address == _Karen7Wallet._CHANGE_ADDR
def test_invalidation_output_value_deducts_fee(self):
"""The output value equals balance minus estimated fee.
balance = sum of input values (from will inputs).
fee = estimated_size * fees_per_byte.
"""
fees_per_byte = 10
result = Will.invalidate_will(
self.will, self.wallet, fees_per_byte
)
assert result is not None
balance = sum(txin.value_sats() for txin in result.inputs()
if txin.value_sats() is not None)
fee = result.estimated_size() * fees_per_byte
expected = balance - fee
assert result.outputs()[0].value == expected, (
f"output value {result.outputs()[0].value} != "
f"expected {expected} (balance={balance}, fee={fee})"
)
def test_all_invalidated_returns_none(self):
"""When all will items are INVALIDATED, returns None."""
for wid in self.will:
self.will[wid].set_status("INVALIDATED", True)
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is None
def test_empty_will_returns_none(self):
"""An empty will dictionary returns None."""
result = Will.invalidate_will({}, self.wallet, 10)
assert result is None

View File

@@ -30,18 +30,21 @@ import time
import traceback import traceback
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest # pyright: ignore[reportMissingImports]
# Make the plugin package importable when run directly (tests/ is one level # Make the plugin package importable when run directly (tests/ is one level
# below the repo root that contains the ``bal`` package). # below the repo root that contains the ``bal`` package).
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import WillItem, Will, HeirNotFoundException
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.reminders import (
compute_reminder_offsets,
format_time,
ical_escape,
write_temp_ics,
)
from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
from bal.gui.qt.calendar import BalCalendar
from bal.gui.qt.widgets import compute_reminder_offsets
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused from test_core_will.py so WillItem can parse a real tx. # version 2). Reused from test_core_will.py so WillItem can parse a real tx.
@@ -197,8 +200,8 @@ def test_e1_build_separate_events_for_karen7():
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"] lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
for idx, offset in enumerate(offsets, start=1): for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset)) event_dt = format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})") summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([ lines.extend([
"BEGIN:VEVENT", "BEGIN:VEVENT",
f"UID:bal-{wallet}-{offset}d", f"UID:bal-{wallet}-{offset}d",
@@ -220,7 +223,7 @@ def test_e1_build_separate_events_for_karen7():
assert any("(reminder 1/3)" in ln for ln in lines) assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines) assert any("(reminder 3/3)" in ln for ln in lines)
# The LAST event (offset 1) sits one day before the locktime. # The LAST event (offset 1) sits one day before the locktime.
last_dt = BalCalendar.format_time(locktime - timedelta(days=1)) last_dt = format_time(locktime - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines assert f"DTSTART:{last_dt}" in lines
@@ -228,7 +231,7 @@ def test_e1_event_description_escaping():
"""Special iCalendar characters in karen7's event text are escaped so """Special iCalendar characters in karen7's event text are escaped so
the .ics file stays valid.""" the .ics file stays valid."""
raw = "Wallet karen7; heirs: alice, bob" raw = "Wallet karen7; heirs: alice, bob"
escaped = BalCalendar.ical_escape(raw) escaped = ical_escape(raw)
assert "\\;" in escaped # semicolon escaped assert "\\;" in escaped # semicolon escaped
assert "\\," in escaped # comma escaped assert "\\," in escaped # comma escaped
assert "karen7" in escaped assert "karen7" in escaped
@@ -246,7 +249,7 @@ def test_e1_write_temp_ics_for_karen7():
"END:VEVENT\r\n" "END:VEVENT\r\n"
"END:VCALENDAR\r\n" "END:VCALENDAR\r\n"
) )
path = BalCalendar.write_temp_ics(content) path = write_temp_ics(content)
try: try:
assert os.path.isfile(path) assert os.path.isfile(path)
with open(path, "rb") as f: with open(path, "rb") as f:
@@ -275,7 +278,7 @@ def test_e2_karen7_add_remove_heir():
heirs["charlie"] = ["addr_charlie", "20000", "30d"] heirs["charlie"] = ["addr_charlie", "20000", "30d"]
assert "charlie" in heirs assert "charlie" in heirs
assert "charlie" in wallet.db.get("heirs", {}) assert "charlie" in (wallet.db.get("heirs") or {})
removed = heirs.pop("alice") removed = heirs.pop("alice")
assert removed is not None assert removed is not None
@@ -550,7 +553,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
for txid, outputs in txo.items(): for txid, outputs in txo.items():
if not isinstance(outputs, dict): if not isinstance(outputs, dict):
continue continue
for addr, out_map in outputs.items(): for _, out_map in outputs.items():
if not isinstance(out_map, dict): if not isinstance(out_map, dict):
continue continue
for idx, info in out_map.items(): for idx, info in out_map.items():
@@ -567,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()
@@ -623,6 +626,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
"bal.core.heirs.PartialTransaction.from_io", "bal.core.heirs.PartialTransaction.from_io",
side_effect=_fake_from_io, side_effect=_fake_from_io,
): ):
result = None
try: try:
result = h.buildTransactions( result = h.buildTransactions(
bal_plugin, wallet, tx_fees=1, utxos=utxos bal_plugin, wallet, tx_fees=1, utxos=utxos

View File

@@ -16,8 +16,9 @@ Run:
python3 tests/test_group_f_heir_change_rebuild.py python3 tests/test_group_f_heir_change_rebuild.py
""" """
import sys
import os import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will from bal.core.will import Will
@@ -112,5 +113,5 @@ def test_same_heirs_empty():
if __name__ == "__main__": if __name__ == "__main__":
import pytest import pytest # pyright: ignore[reportMissingImports]
raise SystemExit(pytest.main([__file__, "-v"])) raise SystemExit(pytest.main([__file__, "-v"]))

View File

@@ -10,13 +10,12 @@ that would fall in the past.
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
drives that decision. drives that decision.
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports ``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like so these tests run without Qt (or Electrum) at all.
the other GUI tests.
Run: Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ source "$BAL_HOME/electrum/env/bin/activate"
python3 -m pytest tests/test_group_g_basic_calendar.py -q python3 tests/test_group_g_basic_calendar.py
""" """
import os import os
@@ -24,8 +23,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS, from bal.core.reminders import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
basic_reminder_offsets)
def test_basic_offsets_all_future(): def test_basic_offsets_all_future():

View File

@@ -3,117 +3,99 @@ Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
Background (reported by the owner): with the plugin left in BASIC mode (the Background (reported by the owner): with the plugin left in BASIC mode (the
default), the "Check Alive" (threshold) field is hidden from the user and default), the "Check Alive" (threshold) field is hidden from the user and
stays stuck at its old/default value (roughly "today + 11 months", i.e. one stays stuck at its old/default value. If the user then anticipates the
year minus the default 30-day margin). If the user then anticipates the delivery time (locktime) to something earlier than that stale threshold, the
delivery time (locktime) to something earlier than that stale threshold - e.g. checks that compare locktime against ``date_to_check`` would incorrectly treat
"in 1 month" - two different checks that compare locktime against the will as "expired"/"invalid", even though the whole Check Alive concept is
``date_to_check`` (the resolved threshold) would incorrectly treat the will as supposed to be inert in BASIC mode.
"expired"/"invalid", even though the whole Check Alive concept is supposed to
be inert in BASIC mode.
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track The real fix lives in ``BalWindow.init_class_variables`` (window.py), which
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it, sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
so it is always < locktime by construction. In ADVANCED mode nothing changes: pure policy in ``bal.core.checkalive``:
the stored threshold is used as-is.
These tests call the real production method directly (no GUI/Electrum wallet * ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
needed - it is a plain ``@staticmethod``), so they exercise the exact code reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
used at runtime rather than a re-implementation. * ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
Check-Alive guard should fire (never in BASIC).
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
needed, and no Qt import).
Run: Run:
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_group_i_basic_checkalive.py
""" """
import os
import sys import sys
import time import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above) from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS def test_basic_mode_resolves_to_now():
"""BASIC mode: date_to_check is "now", never the stale stored threshold."""
fake_now = 1_800_000_000.0
result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
assert result == fake_now
def test_offset_is_two_hours(): def test_basic_mode_ignores_stored_threshold():
"""Owner-approved margin: exactly 2 hours.""" """BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
assert OFFSET == 2 * 60 * 60 does NOT jump to the old ~11-month threshold - this is the exact bug
scenario reported by the owner."""
stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
fake_now = 1_800_000_000.0
result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
assert result == fake_now
def _relative_days_to_midnight_timestamp(days): def test_advanced_mode_uses_stored_threshold():
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N """ADVANCED mode: behaviour stays exactly as before - the stored threshold
days", truncated to midnight. Used to compute the expected value in is used as-is, regardless of the locktime value."""
tests without duplicating the parsing logic itself."""
from datetime import datetime, timedelta
now = datetime.now()
return (
(now + timedelta(days=days))
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
)
def test_basic_mode_relative_locktime_one_year():
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier."""
result = BalWindow.compute_date_to_check(True, "1y", "30d")
expected_locktime = _relative_days_to_midnight_timestamp(365)
assert abs(result - (expected_locktime - OFFSET)) < 5
def test_basic_mode_anticipated_delivery_stays_consistent():
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
NOT the old ~11-month threshold - this is the exact bug scenario
reported by the owner."""
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
result = BalWindow.compute_date_to_check(
True, anticipated_locktime, stale_threshold
)
locktime_ts = _relative_days_to_midnight_timestamp(30)
# date_to_check must be (delivery - 2h), always strictly before delivery.
assert result < locktime_ts
assert abs((locktime_ts - OFFSET) - result) < 5
def test_basic_mode_date_to_check_always_before_locktime():
"""Regression guard for the reported bug: whatever the delivery date is
(even very close to "now"), date_to_check must stay before it."""
for relative_locktime in ("1d", "7d", "30d", "90d", "365d"):
result = BalWindow.compute_date_to_check(True, relative_locktime, "30d")
parsed_locktime = _relative_days_to_midnight_timestamp(
int(relative_locktime[:-1])
)
assert result < parsed_locktime, (
f"date_to_check ({result}) should be before locktime "
f"({parsed_locktime}) for locktime={relative_locktime}"
)
def test_advanced_mode_uses_stored_threshold_unchanged():
"""ADVANCED mode: behaviour must stay exactly as before this fix - the
stored threshold is used as-is, regardless of the locktime value."""
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
result = BalWindow.compute_date_to_check(False, "30d", absolute_threshold) result = resolve_date_to_check(False, {"threshold": absolute_threshold})
assert abs(result - absolute_threshold) < 1 assert abs(result - absolute_threshold) < 1
def test_basic_mode_falls_back_on_unparsable_locktime(): def test_advanced_mode_parses_relative_threshold():
"""If the locktime can't be parsed for any reason, BASIC mode must not """ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
crash: it falls back to the stored threshold, same as ADVANCED.""" result = resolve_date_to_check(False, {"threshold": "30d"})
absolute_threshold = time.time() + 5 * 86400 assert result > time.time()
result = BalWindow.compute_date_to_check(
True, {"not": "a valid locktime"}, absolute_threshold
) def test_basic_mode_never_expired():
assert abs(result - absolute_threshold) < 1 """BASIC mode can never be "expired": a passed check-alive date must never
force a postpone/rewrite of the will."""
assert check_alive_expired(True, time.time() - 10_000) is False
assert check_alive_expired(True, 1_000_000_000.0) is False
def test_advanced_mode_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_mode_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
def test_basic_mode_never_raises_check_alive_error():
"""Regression guard for the reported bug: whatever the delivery date,
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
assert not check_alive_expired(True, date_to_check)
# If it did fire, this is the exact exception that would be raised.
assert issubclass(CheckAliveError, Exception)
if __name__ == "__main__": if __name__ == "__main__":
test_offset_is_two_hours() for name in sorted(dir()):
test_basic_mode_relative_locktime_one_year() if name.startswith("test_"):
test_basic_mode_anticipated_delivery_stays_consistent() globals()[name]()
test_basic_mode_date_to_check_always_before_locktime() print(f" [OK] {name}")
test_advanced_mode_uses_stored_threshold_unchanged() print("[OK] All test_group_i_basic_checkalive tests passed.")
test_basic_mode_falls_back_on_unparsable_locktime()
print("All test_group_i_basic_checkalive tests passed.")

View File

@@ -1,128 +1,25 @@
""" """
Tests for ``bal.gui.qt.calendar``. Tests for ``bal.gui.qt.calendar``.
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line, Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
write_temp_ics, open_with_default_app. The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
""" """
import os
import sys import sys
import tempfile
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.calendar import BalCalendar from bal.gui.qt.calendar import BalCalendar
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert BalCalendar.format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
from datetime import timedelta
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
result = BalCalendar.format_time(dt)
assert result.endswith("Z")
assert result == "20250115T060000Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
text = "hello world"
assert BalCalendar.ical_escape(text) == "hello world"
def test_ical_escape_backslash():
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert BalCalendar.ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert BalCalendar.ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\r\nline2"
result = BalCalendar.ical_escape(text)
assert "\r\n" in result
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
text = "\\;,"
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
line = "SUMMARY:Test"
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "X-LONG:" + "a" * 100
result = BalCalendar.fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert len(parts) > 1
assert result.startswith("X-LONG:")
def test_fold_ical_line_unicode():
line = "DESCRIPTION:" + "\u20ac" * 40
result = BalCalendar.fold_ical_line(line, limit=75)
assert "\r\n " in result
assert "\u20ac" in result
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = BalCalendar.write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = BalCalendar.write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# open_with_default_app # open_with_default_app
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_open_with_default_app_not_found(): def test_open_with_default_app_not_found():
result = BalCalendar.open_with_default_app( result = BalCalendar.open_with_default_app(
"/nonexistent/calendar_app", "/tmp/fake.ics" "/nonexistent/calendar_app", "/tmp/fake.ics"

View File

@@ -1,19 +1,21 @@
""" """
Tests for ``bal.gui.qt.common``. Tests for ``bal.gui.qt.common``.
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui. Covers shown_cv, add_widget, log_error, export_meta_gui (and the
CheckAliveError exception, which now lives in ``bal.core.checkalive``).
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel
# Import the module itself, not via "from .common import *" # Import the module itself, not via "from .common import *"
import bal.gui.qt.common as C import bal.gui.qt.common as common
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
@@ -23,18 +25,18 @@ _app = QApplication.instance() or QApplication(sys.argv)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_shown_cv_default(): def test_shown_cv_default():
cv = C.shown_cv(True) cv = common.shown_cv(True)
assert cv.get() is True assert cv.get() is True
def test_shown_cv_set(): def test_shown_cv_set():
cv = C.shown_cv(True) cv = common.shown_cv(True)
cv.set(False) cv.set(False)
assert cv.get() is False assert cv.get() is False
def test_shown_cv_roundtrip(): def test_shown_cv_roundtrip():
cv = C.shown_cv(False) cv = common.shown_cv(False)
assert cv.get() is False assert cv.get() is False
cv.set(True) cv.set(True)
assert cv.get() is True assert cv.get() is True
@@ -43,23 +45,30 @@ def test_shown_cv_roundtrip():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# CheckAliveError # CheckAliveError (moved to bal.core.checkalive; kept tested via the
# common->core import chain)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_check_alive_error_default(): def test_check_alive_error_default():
err = C.CheckAliveError(1000000) from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000 assert err.timestamp_to_check == 1000000
def test_check_alive_error_str(): def test_check_alive_error_str():
err = C.CheckAliveError(1000000) from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
s = str(err) s = str(err)
assert "Check alive expired" in s assert "Check alive expired" in s
assert "1970" in s assert "1970" in s
def test_check_alive_error_subclass(): def test_check_alive_error_subclass():
assert issubclass(C.CheckAliveError, Exception) from bal.core.checkalive import CheckAliveError
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -68,17 +77,15 @@ def test_check_alive_error_subclass():
def test_add_widget(): def test_add_widget():
grid = QGridLayout() grid = QGridLayout()
parent = QWidget()
label = QLabel("test") label = QLabel("test")
C.add_widget(grid, "Label", label, 0, "Help text") common.add_widget(grid, "Label", label, 0, "Help text")
assert grid.count() == 3 # label + widget + help button assert grid.count() == 3 # label + widget + help button
def test_add_widget_multiple_rows(): def test_add_widget_multiple_rows():
grid = QGridLayout() grid = QGridLayout()
parent = QWidget() common.add_widget(grid, "A", QLabel("a"), 0, "help_a")
C.add_widget(grid, "A", QLabel("a"), 0, "help_a") common.add_widget(grid, "B", QLabel("b"), 1, "help_b")
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
assert grid.count() == 6 assert grid.count() == 6
@@ -87,7 +94,7 @@ def test_add_widget_multiple_rows():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_log_error_no_window(): def test_log_error_no_window():
C.log_error((Exception, Exception("test"), None)) common.log_error((Exception, Exception("test"), None))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,216 @@
"""
Tests for the history-save hook in ``BalWindow``.
Verifies that every successful prepare (including the "Prepare" menu action)
persists the freshly prepared transactions into the wallet's local history,
while abort paths (which return ``None``) skip the save. Also verifies that
after any local-history change (saving or removing will transactions) the
wallet tabs are re-rendered through ``update_tabs`` (all tabs) plus
``update_status`` (status-bar balance), and that the rebuild path uses the same
full refresh.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_prepare_will_history.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import Mock, patch
import bal.gui.qt.window as win_mod
from bal.core.util import Util
from bal.core.will import NotCompleteWillException, Will
from bal.gui.qt.window import BalWindow
# ------------------------------------------------------------------ #
# prepare_will -> _save_will_to_history
# ------------------------------------------------------------------ #
def test_prepare_will_saves_to_history_on_success():
win = object.__new__(BalWindow)
will = {"wid": object()}
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=will)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is will
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_called_once_with()
def test_prepare_will_skips_save_when_build_aborted():
win = object.__new__(BalWindow)
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=None)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is None
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_not_called()
# ------------------------------------------------------------------ #
# history refresh: _save_will_to_history -> update_tabs + update_status
# ------------------------------------------------------------------ #
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class _CfgBag:
def __init__(self, **kwargs):
for name, value in kwargs.items():
setattr(self, name, value)
class _Wallet:
def dust_threshold(self):
return 546
class _FakeWindow:
def __init__(self, *, with_update_tabs=True):
self.show_message = Mock()
self.update_status = Mock()
self.history_list = Mock()
self.history_list.update = Mock()
if with_update_tabs:
self.update_tabs = Mock()
class _FakeQTimer:
calls = []
@classmethod
def singleShot(cls, delay, callable_):
cls.calls.append((delay, callable_))
def _make_save_window(save_enabled):
win = object.__new__(BalWindow)
win.bal_plugin = _CfgBag(
SAVE_HISTORY=_Cfg(save_enabled), HISTORY_LABEL=_Cfg("LBL")
)
win.willitems = {"wid": object()}
win.wallet = object()
return win
def test_save_will_to_history_schedules_refresh_when_enabled():
win = _make_save_window(save_enabled=True)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_called_once_with(win.willitems, win.wallet, "LBL")
schedule_mock.assert_called_once_with()
def test_save_will_to_history_skips_save_and_refresh_when_disabled():
win = _make_save_window(save_enabled=False)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_not_called()
schedule_mock.assert_not_called()
def test_save_will_to_history_schedules_refresh_even_on_error():
win = _make_save_window(save_enabled=True)
with (
patch.object(
Will,
"save_valid_transactions_to_history",
side_effect=RuntimeError("boom"),
),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
schedule_mock.assert_called_once_with()
def test_schedule_history_refresh_marshals_to_gui_thread():
win = object.__new__(BalWindow)
with patch.object(win_mod, "QTimer", _FakeQTimer):
_FakeQTimer.calls.clear()
BalWindow._schedule_history_refresh(win)
assert _FakeQTimer.calls == [(0, win._refresh_after_history_save)]
def test_refresh_after_history_save_calls_update_tabs_and_status():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=True)
BalWindow._refresh_after_history_save(win)
win.window.update_tabs.assert_called_once_with()
win.window.update_status.assert_called_once_with()
win.window.history_list.update.assert_not_called()
def test_refresh_after_history_save_falls_back_to_history_list():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=False)
BalWindow._refresh_after_history_save(win)
win.window.history_list.update.assert_called_once_with()
win.window.update_status.assert_called_once_with()
def test_rebuild_path_schedules_full_refresh():
win = object.__new__(BalWindow)
win.disable_plugin = False
win.heirs = {"h": object()}
win.willexecutors = {}
win.no_willexecutor = True
win.willitems = {}
win.will = {}
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
)
win.window = _FakeWindow()
win.window.wallet = _Wallet()
with (
patch.object(Util, "get_available_utxos", return_value=[]),
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
patch.object(Will, "get_min_locktime", return_value=0),
patch.object(Will, "check_amounts"),
patch.object(BalWindow, "init_class_variables"),
patch.object(BalWindow, "build_will"),
patch.object(
BalWindow,
"check_will",
side_effect=[NotCompleteWillException(), None],
),
patch.object(BalWindow, "update_all"),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow.build_inheritance_transaction(win)
schedule_mock.assert_called_once_with()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All prepare-will history tests passed")

View File

@@ -8,16 +8,19 @@ Run:
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.theme import status_color from bal.gui.qt.theme import signature_suffix, status_color
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Helpers # Helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
class FakeWillItem: class FakeWillItem:
sigs_required = 0
sigs_have = 0
def __init__(self, **status_flags): def __init__(self, **status_flags):
self._status = dict(status_flags) self._status = dict(status_flags)
def get_status(self, name): def get_status(self, name):
@@ -97,6 +100,54 @@ def test_color_check_fail_overrides_push_fail():
assert status_color(item) == "#e83845" assert status_color(item) == "#e83845"
# ------------------------------------------------------------------ #
# PARTIALLY_SIGNED
# ------------------------------------------------------------------ #
def test_color_partially_signed():
assert status_color(FakeWillItem(PARTIALLY_SIGNED=True)) == "#ffb347"
def test_color_partially_signed_overridden_by_higher_priority():
item = FakeWillItem(PARTIALLY_SIGNED=True, PUSHED=True)
assert status_color(item) == "#73f3c8"
# ------------------------------------------------------------------ #
# signature_suffix
# ------------------------------------------------------------------ #
def test_signature_suffix_partial():
item = FakeWillItem(PARTIALLY_SIGNED=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == " (1/2)"
def test_signature_suffix_new():
item = FakeWillItem()
item.sigs_required = 2
item.sigs_have = 0
assert signature_suffix(item) == " (0/2)"
def test_signature_suffix_complete_empty():
item = FakeWillItem(COMPLETE=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_unknown_required_empty():
item = FakeWillItem()
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_missing_fields_empty():
assert signature_suffix(FakeWillItem()) == ""
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Main # Main
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -13,13 +13,11 @@ Run:
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QWidget from PyQt6.QtWidgets import QApplication, QWidget
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
@@ -120,23 +118,25 @@ def test_locktime_editor_min_max():
def test_locktime_raw_edit_replace_str(): def test_locktime_raw_edit_replace_str():
# replace_str only strips the day ("d") and year ("y") suffixes. The # replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped # block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now). # anymore (locktimes are always UNIX timestamps now). The helper moved to
from bal.gui.qt.widgets import LockTimeRawEdit # bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
assert LockTimeRawEdit.replace_str("123d") == "123" from bal.core.input_rules import replace_dy_suffixes
assert LockTimeRawEdit.replace_str("456y") == "456" assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix) # "b" is left untouched (no longer a recognised suffix)
assert LockTimeRawEdit.replace_str("789b") == "789b" assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains # only d/y are stripped; a stray "b" remains
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b" assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_locktime_raw_edit_checkbdy(): def test_locktime_raw_edit_checkbdy():
from bal.gui.qt.widgets import LockTimeRawEdit # checkbdy moved to bal.core.input_rules (_checkbdy).
from bal.core.input_rules import _checkbdy
# character at expected position matches appendix # character at expected position matches appendix
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d") pos, s = _checkbdy("123d", 4, "d")
assert s == "123d" assert s == "123d"
# character at expected position does not match # character at expected position does not match
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d") pos, s = _checkbdy("123x", 4, "d")
assert s == "123x" assert s == "123x"

View File

@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""End-to-end GUI-level tests for the BAL will flows.
Exercises the real ``BalWindow`` controller (built via ``__new__`` to skip
the heavyweight tab construction) with a fake wallet/window, through the full
will lifecycle:
* prepare/build + persist (``prepare_will``)
* sign -> COMPLETE (``sign_transactions``)
* export -> merge roundtrip (``export_json_file`` / ``_load_will_file`` /
``merge_will``)
* insufficient-funds warning path
Run offline under the runtime venv:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_flows.py
"""
import json
import os
import sys
import tempfile
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,
tx_from_any,
)
from electrum.util import bfh # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from bal.core.heirs import Heirs # noqa: E402
from bal.core.plugin_base import 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.window import BalWindow # noqa: E402
# --------------------------------------------------------------------------- #
# 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():
"""Return a fake wallet UTXO spent by the will.
The input has no descriptor attached: the controller only needs
prevout + value + script for coin selection, and the wallet's
``sign_transaction`` re-attaches the descriptor after the PSBT
roundtrip drops it.
"""
utxo = PartialTxInput(prevout=TxOutpoint(bfh("11" * 32), 0))
# A real wallet input carries a witness UTXO, which is the ONLY field that
# survives the PSBT roundtrip the controller does (WillItem re-parses the tx
# via get_tx_from_any). Without it the re-parsed tx loses the input value
# and has no txid.
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
# -- wallet API used by the BAL core ---------------------------------- #
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
# -- signing ----------------------------------------------------------- #
def sign_transaction(self, tx, password=None, ignore_warnings=True):
# The controller passes a re-parsed transaction whose inputs lost the
# descriptor and the trusted value through the PSBT roundtrip.
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)
# BalWindow.__init__ wires these onto the plugin; replicate since we skip it.
plugin.get_decimal_point = window.get_decimal_point
# Self-will-executor mode; init_class_variables() reads this from config and
# would otherwise overwrite ctl.no_willexecutor with the (False) default.
plugin.NO_WILLEXECUTOR.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.update_all = lambda: None
ctl._schedule_history_refresh = lambda: None
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()))
# --------------------------------------------------------------------------- #
# Tests
# --------------------------------------------------------------------------- #
def test_prepare_will_builds_and_persists():
with _no_willexecutors():
ctl = make_controller()
will = ctl.prepare_will()
assert will, "prepare_will must return the built will"
txid, item = _single(ctl)
assert item.get_status("VALID"), "fresh items default to VALID"
assert txid == item.tx.txid()
assert isinstance(txid, str) and len(txid) == 64 and all(
c in "0123456789abcdef" for c in txid
), "raw tx id expected (64-char hex, not a label/short id)"
assert not item.tx.is_complete(), "unsigned will must not be complete"
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
# Will item metadata mirrors the funded heir.
heirs = item.heirs
assert len(heirs) == 2
assert heirs["alice"][0] == ADDRESS and int(heirs["alice"][1]) == 100000
assert heirs["bob"][0] == ADDRESS
assert int(heirs["bob"][3]) > 0, "percent heir got a real amount"
assert int(heirs["alice"][3]) > 0
# The full wallet balance is swept into the will; the only leftover is the
# transaction fee.
total = sum(int(h[3]) for h in heirs.values())
assert FUNDING_SATOSHIS - total == item.tx.get_fee()
# Unsigned partial transaction serialises to PSBT base64.
serialized = str(item.tx)
assert not serialized.startswith("02"), "partial must not be raw hex"
# Nothing was pushed to the live history (no adb).
assert "history" not in ctl.wallet.db._data
assert ctl.wallet.save_db_calls == 0
# The rebuilt, ready-to-sign will is labelled for Electrum's History tab and
# the user is guided to the next manual step (sign).
assert ctl.wallet.labels[txid] == "BAL Inheritance transaction"
assert any("sign" in m.lower() for m in ctl.window.messages)
assert not ctl.window.warnings, "a clean build must not warn"
def test_check_will_passes_after_build():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
assert ctl.check_will() is True
def test_sign_transitions_to_complete():
with _no_willexecutors():
ctl = make_controller()
ctl.prepare_will()
txid, item = _single(ctl)
txs = ctl.sign_transactions(None)
assert txs and txid in txs
signed = txs[txid]
assert signed.is_complete(), "single-key p2wpkh must be fully signable"
# Mirror BalWindow.ask_password_and_sign_transactions.on_success: store
# the signed tx back into the will item and re-check signatures.
item.tx = Will.get_tx_from_any(str(signed))
Will.check_signatures(ctl.willitems, ctl.wallet)
assert item.get_status("COMPLETE"), "signed item must become COMPLETE"
assert item.sigs_have == 1 and item.sigs_required == 1
assert item.tx.inputs()[0].is_segwit()
# A complete tx serialises to raw hex.
raw = str(item.tx)
assert raw.startswith("02")
# The raw serialization must roundtrip back into the same complete tx.
reparsed = tx_from_any(raw)
assert reparsed.is_complete()
assert reparsed.txid() == signed.txid()
def test_export_and_merge_roundtrip():
with _no_willexecutors():
src = make_controller()
src.prepare_will()
txid, _ = _single(src)
signed = src.sign_transactions(None)
item = src.willitems[txid]
item.tx = Will.get_tx_from_any(str(signed[txid]))
Will.check_signatures(src.willitems, src.wallet)
assert item.get_status("COMPLETE")
tmpdir = tempfile.mkdtemp(prefix="bal-export-")
path = os.path.join(tmpdir, "will.json")
src.export_json_file(path, will=src.willitems)
# The exported file is a JSON dict keyed by txid; the signed tx is
# serialised as raw hex via MyEncoder.
with open(path) as f:
exported = json.load(f)
assert list(exported) == [txid]
assert exported[txid]["tx"] == str(item.tx)
assert exported[txid]["COMPLETE"] is True
assert exported[txid]["VALID"] is True
# Fresh controller receives the exported will.
dst = make_controller()
imported = dst._load_will_file(path)
dst.merge_will(imported)
assert len(dst.willitems) == 1
dst_txid, dst_item = _single(dst)
assert dst_txid == txid, "merge must keep the same txid"
assert dst_item.get_status("COMPLETE"), "signed status must survive merge"
assert str(dst_item.tx) == str(item.tx), "signed raw tx must survive roundtrip"
assert dst_item.tx.is_complete()
def test_insufficient_funds_warns():
with _no_willexecutors():
ctl = make_controller(utxos=[])
will = ctl.prepare_will()
assert not will, "no wallet funds must not build a will"
assert ctl.window.warnings, "user must be warned about the balance"
assert any("adjustment" in w.lower() for w in ctl.window.warnings)
assert not ctl.willitems
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()

150
tests/test_gui_will_menu.py Normal file
View File

@@ -0,0 +1,150 @@
"""
Tests for the Will-tab context menu and Will-Executor bulk-selection helpers
in ``bal.gui.qt.lists``.
Covers ``_can_sign`` / ``_can_broadcast`` / ``_can_delete`` and
``_apply_select_all`` (the pure logic behind the new context-menu actions and
the "Select All" dropdown).
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_menu.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.lists import (
_apply_select_all,
_can_broadcast,
_can_delete,
_can_sign,
)
class FakeWillItem:
"""Minimal stand-in for ``bal.core.will.WillItem`` (get_status only)."""
def __init__(self, **status_flags):
self._status = dict(status_flags)
def get_status(self, name):
return self._status.get(name, False)
def _we(selected=False):
return {"selected": selected}
# ------------------------------------------------------------------ #
# _can_sign / _can_broadcast
# ------------------------------------------------------------------ #
def test_can_sign_unsigned():
assert _can_sign(FakeWillItem()) is True
def test_can_sign_partially_signed():
assert _can_sign(FakeWillItem(PARTIALLY_SIGNED=True)) is True
def test_can_sign_complete_false():
assert _can_sign(FakeWillItem(COMPLETE=True)) is False
def test_can_sign_none_false():
assert _can_sign(None) is False
def test_can_broadcast_complete():
assert _can_broadcast(FakeWillItem(COMPLETE=True)) is True
def test_can_broadcast_unsigned_false():
assert _can_broadcast(FakeWillItem()) is False
def test_can_broadcast_none_false():
assert _can_broadcast(None) is False
# ------------------------------------------------------------------ #
# _can_delete
# ------------------------------------------------------------------ #
def test_can_delete_invalid():
assert _can_delete(FakeWillItem(VALID=False, COMPLETE=True)) is True
def test_can_delete_unsigned():
assert _can_delete(FakeWillItem(VALID=True)) is True
def test_can_delete_invalid_and_unsigned():
assert _can_delete(FakeWillItem(VALID=False)) is True
def test_can_delete_valid_and_complete_false():
assert _can_delete(FakeWillItem(VALID=True, COMPLETE=True)) is False
def test_can_delete_none_false():
assert _can_delete(None) is False
# ------------------------------------------------------------------ #
# _apply_select_all
# ------------------------------------------------------------------ #
def test_select_all_sets_everything():
wes = {"a": _we(False), "b": _we(False)}
_apply_select_all(wes, True)
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is True
def test_deselect_all_clears_everything():
wes = {"a": _we(True), "b": _we(True)}
_apply_select_all(wes, False)
assert wes["a"]["selected"] is False
assert wes["b"]["selected"] is False
def test_select_only_valid():
wes = {"a": _we(False), "b": _we(True), "c": _we(True)}
valid = {"a": True, "b": True, "c": False}
_apply_select_all(wes, True, valid)
# a, b are valid -> selected; c is invalid -> deselected
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is True
assert wes["c"]["selected"] is False
def test_deselect_only_invalid():
wes = {"a": _we(True), "b": _we(True), "c": _we(True)}
valid = {"a": True, "b": False, "c": True}
_apply_select_all(wes, False, valid)
# b is invalid -> deselected; a, c are valid -> keep their state
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is False
assert wes["c"]["selected"] is True
def test_select_only_valid_missing_url_treated_invalid():
wes = {"a": _we(False), "b": _we(True)}
valid = {"a": True}
_apply_select_all(wes, True, valid)
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All will-menu tests passed")

View File

@@ -8,13 +8,18 @@ Run:
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QDialog, QWidget from PyQt6.QtWidgets import QApplication, QDialog, QWidget
from bal.gui.qt.window_utils import ( from bal.gui.qt.window_utils import (
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of, bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
) )
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -0,0 +1,226 @@
"""
Tests for the relative-recipe anchoring in the will coherence check.
Regression for the reported bug: a wallet built with RELATIVE locktimes
(``"1y"`` on the heirs, relative will_settings) was asked to invalidate the
will EVERY DAY. The relative recipes were re-parsed against *now* on every
check, so they drifted one day per day away from the fixed locktime frozen
inside the signed transaction and the check mistook the (unchanged) will for a
POSTPONE / EXPIRED one.
The two gates that produced the prompt are covered here:
1. ``Will.check_willexecutors_and_heirs`` must treat an UNCHANGED relative
recipe as coherent (resolved against the build moment, not "now"), while
still detecting a genuinely lengthened recipe as a postpone.
2. ``resolve_date_to_check`` (ADVANCED mode) must anchor a relative stored
locktime to the built transactions' frozen delivery date, so the will is
never read as EXPIRED because the check window drifts past the frozen
tx locktime.
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
``{"locktime": "2y", "threshold": "150d"}``.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_heir_relative_anchor.py
"""
import copy
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import constants # noqa: E402 (path insert above)
constants.net = constants.BitcoinRegtest
from bal.core.checkalive import resolve_date_to_check # noqa: E402
from bal.core.will import ( # noqa: E402
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
Will,
WillItem,
WillPostponedException,
)
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05 00:00 UTC,
# i.e. a will built 2026-08-05 with a "1y" recipe.
_FROZEN = 1817424000
def _make_will_item(heirs, tx_locktime, status_complete=False):
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 1,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
return item
def _run_heir_check(will_heirs, current_heirs, tx_locktime, status_complete):
"""Run ``check_willexecutors_and_heirs`` and return the outcome."""
item = _make_will_item(will_heirs, tx_locktime, status_complete)
will = {"willid_1": item}
try:
result = Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 1
)
return f"coherent ({result})"
except WillPostponedException as e:
return f"POSTPONE: {e}"
except HeirNotFoundException as e:
return f"rebuild: {e}"
except NoHeirsException as e:
return f"NoHeirs: {e}"
except NotCompleteWillException as e:
return f"NotComplete: {e}"
def test_unchanged_relative_recipe_signed_is_coherent():
"""The reported bug: an unchanged "1y" recipe on a signed will must NOT be
read as a postpone just because the clock has advanced past build day."""
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
def test_unchanged_relative_recipe_unsigned_is_coherent():
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
)
assert outcome.startswith("coherent"), outcome
def test_relative_recipe_lengthened_on_signed_is_postpone():
"""A genuinely lengthened recipe ("1y" -> "2y") on a signed/sent will is
still detected as a postpone (must invalidate on-chain first)."""
built = {"alice": ["addr_alice", 5000, "1y"]}
now = {"alice": ["addr_alice", 5000, "2y"]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("POSTPONE"), outcome
def test_relative_recipe_shortened_on_signed_is_rebuild():
"""A shortened recipe ("1y" -> "30d") is an ANTICIPATE: plain rebuild, no
on-chain invalidation."""
built = {"alice": ["addr_alice", 5000, "1y"]}
now = {"alice": ["addr_alice", 5000, "30d"]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("rebuild"), outcome
def test_unchanged_absolute_recipe_is_coherent():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
outcome = _run_heir_check(
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
def test_absolute_postpone_on_signed_still_detected():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
now = {"alice": ["addr_alice", 5000, str(_FROZEN + 86400)]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("POSTPONE"), outcome
# ------------------------------------------------------------------ #
# karen7 wallet regression (real fixture)
# ------------------------------------------------------------------ #
def _load_karen7():
path = os.path.join(os.path.dirname(__file__), "karen7")
with open(path) as f:
return json.load(f)
def test_karen7_frozen_delivery_not_expired():
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
window opens BEFORE the delivery, so the will is never read as expired."""
data = _load_karen7()
will_settings = data["will_settings"]
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime == int(wi.tx.locktime)
date_to_check = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
)
assert int(date_to_check) < built_locktime
# Re-evaluated 10 days later the window is identical (no daily drift).
later = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
built_locktime=built_locktime,
)
assert date_to_check == later
def test_karen7_unchanged_heirs_are_coherent():
"""The karen7 heirs (unchanged relative "2d") are coherent with the frozen
signed tx: the plugin must NOT ask to invalidate the will."""
data = _load_karen7()
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
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(
False, data["will_settings"],
now=1_800_000_000.0,
built_locktime=frozen_locktime,
)
outcome = _run_heir_check(
data["will"][valid_wid]["heirs"],
data["heirs"],
frozen_locktime,
status_complete=True,
)
assert outcome.startswith("coherent"), outcome
assert int(date_to_check) < frozen_locktime
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All heir-relative-anchor tests passed")

View File

@@ -0,0 +1,603 @@
"""
Tests for the "Import" (read-only will preview) flow.
Covers:
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
- ``import_will_into_details`` normalization + IMPORTED status.
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
will, never on the live wallet state.
- ``WillWidget`` honouring an explicit ``will`` argument.
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
"""
import sys
import tempfile
from types import MethodType, SimpleNamespace
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QWidget
from bal.core.will import Will, WillItem
from bal.gui.qt import window as window_mod
_app = QApplication.instance() or QApplication(sys.argv)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
def _make_willitem_dict(**overrides):
"""Return a minimal dict that can construct a WillItem."""
d = {
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
}
d.update(overrides)
return d
def _make_willitems(n, prefix="w"):
"""Create ``n`` WillItems with distinct txids/locktimes."""
willitems = {}
for i in range(n):
wid = f"{prefix}{i}"
wi = WillItem(_make_willitem_dict())
wi.tx.locktime = 1000 + i
wi._id = wid
willitems[wid] = wi
return willitems
# ------------------------------------------------------------------ #
# _load_will_file round-trip
# ------------------------------------------------------------------ #
def test_load_will_file_roundtrip():
from bal.gui.qt.common import write_json_file
src = _make_willitems(2)
data = {wid: wi.to_dict() for wid, wi in src.items()}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
path = f.name
write_json_file(path, data)
try:
loaded = window_mod.BalWindow._load_will_file(None, path)
assert set(loaded) == {"w0", "w1"}
for wid, wi in loaded.items():
assert isinstance(wi, WillItem)
assert wi.heirs == {}
assert wi._id == wid
assert wi.tx is not None
finally:
import os
os.unlink(path)
# ------------------------------------------------------------------ #
# import_will_into_details normalization + IMPORTED status
# ------------------------------------------------------------------ #
def test_imported_will_is_normalized_and_marked_imported():
willitems = _make_willitems(1)
Will.normalize_will(willitems, None)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
assert all(wi.get_status("VALID") for wi in willitems.values())
# ------------------------------------------------------------------ #
# sign_transactions operates only on the passed (imported) will
# ------------------------------------------------------------------ #
def test_sign_transactions_external_only():
class FakeWallet:
def sign_transaction(self, tx, password, ignore_warnings=False):
# No-op: never marks the tx complete.
return None
live = _make_willitems(1, prefix="live")
wid_live = next(iter(live))
imported = _make_willitems(2, prefix="imp")
fake = SimpleNamespace(
willitems=live,
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
assert result is not None
assert set(result) == set(imported)
assert wid_live not in result
# The live will must be completely untouched by the external sign run.
assert live[wid_live].get_status("COMPLETE") is False
# ------------------------------------------------------------------ #
# WillWidget honours an explicit ``will`` argument
# ------------------------------------------------------------------ #
def test_will_widget_explicit_will():
from bal.gui.qt.widgets import WillWidget
live = _make_willitems(1, prefix="live")
imported = _make_willitems(2, prefix="imp")
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems=live,
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will=imported)
assert w.will is imported
w2 = WillWidget(parent=fake_parent)
assert w2.will is live
# ------------------------------------------------------------------ #
# WillDetailDialog external-will mode
# ------------------------------------------------------------------ #
def _make_fake_bal_window(window_widget):
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
return SimpleNamespace(
window=window_widget,
bal_plugin=bal_plugin,
wallet=SimpleNamespace(),
show_transaction=lambda *a, **k: None,
willitems=_make_willitems(1),
will_settings={"real_threshold": 9999},
)
def test_will_detail_dialog_external_threshold():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(2)
# locktimes 1000 and 1001 -> threshold must be the max (1001).
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.will is imported
assert dialog.threshold == 1001
dialog2 = WillDetailDialog(bal_window)
assert dialog2._external_will is False
assert dialog2.will is bal_window.willitems
assert dialog2.threshold == 9999
def test_will_detail_dialog_buttons_pass_will():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(1)
dialog = WillDetailDialog(bal_window, will=imported)
calls = []
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
("sign", k.get("will"))
)
bal_window.broadcast_transactions = lambda **k: calls.append(
("broadcast", k.get("will"))
)
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
bal_window.invalidate_will = lambda **k: calls.append(
("invalidate", k.get("will"))
)
dialog.ask_password_and_sign_transactions()
dialog.broadcast_transactions()
dialog.export_will()
dialog.invalidate_will()
assert len(calls) == 4
for action, will in calls:
assert will is imported, f"{action} did not receive the imported will"
# ------------------------------------------------------------------ #
# Merge flow (BalWindow.merge_will)
# ------------------------------------------------------------------ #
def _make_partial_tx(locktime=1000, signed=False):
"""A PartialTransaction derived from _VALID_TX_HEX.
When ``signed`` the input scriptSig of the raw tx is copied over, which
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
"""
from electrum.transaction import PartialTransaction, Transaction
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
ptx.locktime = locktime
if signed:
raw = Transaction(_VALID_TX_HEX)
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
return ptx
def _make_willitem_with_tx(tx, key=None):
wi = WillItem(_make_willitem_dict())
wi.tx = tx
wi._id = key if key is not None else tx.txid()
return wi
def _make_merge_fake(willitems):
"""A BalWindow-like object with a wallet stub sufficient for the local
validity check that ``merge_will`` runs after merging.
"""
class FakeWallet:
def add_input_info(self, txin, **kwargs):
pass
def add_output_info(self, txout, **kwargs):
pass
def get_utxos(self):
return []
def get_tx_info(self, tx):
return SimpleNamespace(
tx_mined_status=SimpleNamespace(height=lambda: 0)
)
@property
def db(self):
return SimpleNamespace(get_transaction=lambda txid: None)
calls = []
fake = SimpleNamespace(
willitems=willitems,
will={},
wallet=FakeWallet(),
bal_window=None,
date_to_check=1700000000,
will_settings={"threshold": 1700000000},
bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})"
),
# BASIC is the default user type; merge_will resolves date_to_check
# through bal.core.checkalive when it is missing.
is_basic_mode=lambda: True,
),
update_all=lambda: calls.append("update_all"),
)
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
return fake, calls
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
# The realistic "signed on another machine, imported here" case: the live
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
# (complete). The transaction must be substituted and COMPLETE set.
wid = "same_id"
live_tx = _make_partial_tx(locktime=1000, signed=False)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
imported[wid].set_status("CHECKED", True)
fake, calls = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# The live WillItem object is kept (never replaced) and the signed tx
# substituted in.
assert live[wid] is live_item
assert live_item.tx is imported_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
# Operational statuses were carried over.
assert live_item.get_status("PUSHED") is True
assert live_item.get_status("CHECKED") is True
# The will was saved and the GUI refreshed.
assert wid in fake.will
assert fake.will[wid]["COMPLETE"] is True
assert "update_all" in calls
def test_merge_will_same_id_both_signed_combines():
# Live tx is signed but the COMPLETE flag was not set yet; the imported
# signed tx with the same txid must be COMBINED into the live one (the live
# tx object is kept) rather than substituted.
from electrum.transaction import Transaction
wid = "same_id_combine"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
txid = imported_tx.txid()
assert txid is not None
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# Same txid -> combine_with_other_psbt: live tx object is preserved.
assert live_item.tx is live_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
assert live_item.tx.inputs()[0].script_sig == raw_sig
def test_merge_will_same_id_already_complete_never_touches_live_tx():
wid = "already_complete"
live_tx = _make_partial_tx(locktime=1000, signed=False)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("COMPLETE", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# An already-signed live will is left untouched: no combine, no substitute.
assert live_item.tx is live_tx
assert live_item.tx.is_complete() is False
assert live_item.tx.inputs()[0].script_sig is None
assert live_item.get_status("COMPLETE") is True
assert live_item.get_status("PUSHED") is True
def test_merge_will_statuses_are_monotonic():
# Statuses that are True in the live item must never be cleared by a False
# value coming from the imported item.
wid = "monotonic"
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("CONFIRMED", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("MEMPOOL", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("CONFIRMED") is True
assert live_item.get_status("MEMPOOL") is True
assert live_item.get_status("COMPLETE") is True
def test_merge_will_new_ids_added_wholesale():
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
imported_tx = _make_partial_tx(locktime=2000, signed=True)
new_id = imported_tx.txid()
imported = {new_id: _make_willitem_with_tx(imported_tx)}
imported[new_id].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
window_mod.BalWindow.merge_will(fake, imported)
assert len(live) == 2
assert live[new_id] is imported[new_id]
assert live[new_id].get_status("PUSHED") is True
assert new_id in fake.will
def test_merge_will_missing_date_to_check_defaults_to_now():
# Regression: merge_will read self.date_to_check (which is only set by
# init_class_variables) and crashed with AttributeError when merging a
# will file was the first action of a session.
wid = "missing_dtc"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, calls = _make_merge_fake({})
del fake.date_to_check
window_mod.BalWindow.merge_will(fake, imported)
assert isinstance(fake.date_to_check, (int, float)), \
"date_to_check must fall back to a timestamp"
assert wid in fake.will
assert "update_all" in calls
def test_merge_will_validity_error_logs_without_crashing():
# Regression: the except handler passed log_error(e, self.bal_window);
# BalWindow has no such attribute, so a validity failure raised a second
# AttributeError that masked the original error.
from unittest.mock import patch
wid = "validity_err"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, calls = _make_merge_fake({})
fake.show_error = lambda msg: calls.append(("show_error", msg))
with patch.object(
window_mod.Util, "get_available_utxos", side_effect=RuntimeError("boom")
):
window_mod.BalWindow.merge_will(fake, imported)
assert any(c[0] == "show_error" for c in calls), \
"the validity error must be surfaced via show_error"
assert "update_all" in calls, "merge must complete after the error"
def test_merge_will_from_file_invalid_file_raises():
from bal.gui.qt.common import FileImportFailed
def bad_load(path):
raise ValueError("bad file")
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
try:
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
except FileImportFailed as e:
assert "bad file" in str(e)
else:
raise AssertionError("expected FileImportFailed")
def test_merge_will_partial_signatures_update_counts():
# A live unsigned 2-of-3 multisig will merged with an imported copy that
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
# refreshed on the live item (check_signatures runs after the merge).
from binascii import unhexlify
from electrum import crypto
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
TxOutpoint,
)
def make_multisig_tx(nsigs):
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
from electrum.bitcoin import public_key_to_p2wpkh
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
ptx = PartialTransaction()
ptx.add_inputs([txin])
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
ptx.locktime = 1000
return ptx, desc
wid = "multisig_will"
live_tx, _ = make_multisig_tx(0)
imported_tx, _ = make_multisig_tx(1)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("PARTIALLY_SIGNED") is True
assert live_item.sigs_have == 1
assert live_item.sigs_required == 2
def test_will_detail_dialog_merge_switches_to_live():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
live = bal_window.willitems
imported = _make_willitems(2)
merged = []
bal_window.merge_will = lambda will: merged.append(will)
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.merge_button is not None
assert dialog.merge_button.isHidden() is False
dialog.merge_will()
assert merged == [imported]
assert dialog._external_will is False
assert dialog.will is live
assert dialog.threshold == bal_window.will_settings["real_threshold"]
assert dialog.merge_button.isHidden() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All import-will-details tests passed")

View File

@@ -0,0 +1,612 @@
"""
Test the error when no will-executor is selected and ``no_willexecutor``
is ``False`` (the "Add transactions without willexecutor" checkbox is
unchecked), using the real **karen7** regtest wallet.
Scenarios covered by this test
------------------------------
A. ``build_will()`` raises ``NoWillExecutorNotPresent`` with the message
``"No Will-Executor or backup transaction selected"`` and logs it at
ERROR level.
B. ``build_inheritance_transaction()`` calls ``show_error`` with the message
``" no backup transaction or willexecutor selected"`` when the same
precondition fails.
C. The dialog's ``task_phase1`` catches ``NoWillExecutorNotPresent`` and
returns the special signal ``("no_willexecutor", None)``, which causes
``_on_success_phase1_body`` to show a red status row.
D. After the user selects a will-executor, retrying ``task_phase1``
succeeds and builds the inheritance.
Run::
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
"""
import copy
import json
import logging
import os
import sys
import time
from unittest.mock import patch
import pytest # pyright: ignore[reportMissingImports]
from electrum import constants
constants.net = constants.BitcoinRegtest
_VALID_REG_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import bitcoin
from electrum.transaction import PartialTxInput, TxOutpoint
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.will import (
NotCompleteWillException,
NoWillExecutorNotPresent,
Will,
WillItem,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Load karen7 wallet data
# ------------------------------------------------------------------ #
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
with open(_WALLET_PATH) as _f:
_KAREN7_DATA = json.load(_f)
# ------------------------------------------------------------------ #
# Minimal wallet stub
# ------------------------------------------------------------------ #
class _Karen7Wallet:
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
def __init__(self, utxos):
self._utxos = utxos
self.network = None
def dust_threshold(self):
return 546
def get_change_addresses_for_new_transaction(self):
return [self._CHANGE_ADDR]
def get_utxos(self):
return self._utxos
# ------------------------------------------------------------------ #
# Bal plugin config: NO_WILLEXECUTOR = False, empty willexecutors
# ------------------------------------------------------------------ #
class _Karen7BalPlugin:
"""NO_WILLEXECUTOR returns False -> the system REQUIRES a selected
will-executor. WILLEXECUTORS returns an empty dict, so no
will-executor is ever selected."""
class _ToggleAttr:
"""Config stub whose value can be toggled from outside."""
def __init__(self, initial=None):
self._value = initial
def get(self, *a, **kw):
return self._value
def set(self, v):
self._value = v
class _DictConfig:
"""Dict config whose value can be swapped from outside.
Mirrors the real ``BalConfig`` interface: ``.get()`` returns
the stored dict, ``.set()`` replaces it, and ``.default``
provides the fallback defaults.
"""
def __init__(self, value, default):
self._data = value
self.default = default
def get(self, *a, **kw):
return self._data
def set(self, v):
self._data = v
def __init__(self):
import bal.core.willexecutors as _we
_we.chainname = "regtest"
self._no_willexecutor = self._ToggleAttr(False)
self._willexecutors = self._DictConfig(
{"regtest": {}},
default={"regtest": {}},
)
self._will_settings = self._DictConfig(
{"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
default={"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
)
self._max_fee = self._ToggleAttr(500000)
self._user_type = self._ToggleAttr("simple")
self._enable_multiverse = self._ToggleAttr(False)
@property
def NO_WILLEXECUTOR(self):
return self._no_willexecutor
@NO_WILLEXECUTOR.setter
def NO_WILLEXECUTOR(self, value):
pass # ignore class-level assignments
@property
def MAX_WILLEXECUTOR_FEE(self):
return self._max_fee
@property
def WILLEXECUTORS(self):
return self._willexecutors
@property
def WILL_SETTINGS(self):
return self._will_settings
@property
def USER_TYPE(self):
return self._user_type
@property
def ENABLE_MULTIVERSE(self):
return self._enable_multiverse
def get_decimal_point(self):
return 8
def is_basic_mode(self):
return True
# ------------------------------------------------------------------ #
# Build real UTXOs from karen7 data
# ------------------------------------------------------------------ #
def _build_real_utxos(data):
utxos = []
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
prevout = TxOutpoint(txid=bfh(txid), out_idx=int(idx))
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
txin._TxInput__address = addr
txin._TxInput__scriptpubkey = bitcoin.address_to_script(addr)
txin.is_mine = True
utxos.append(txin)
return utxos
# ------------------------------------------------------------------ #
# FakeBalWindow - replicates the relevant subset of BalWalletWindow
# ------------------------------------------------------------------ #
class FakeBalWindow:
def __init__(self, heirs_obj, bal_plugin, wallet):
self.heirs = heirs_obj
self.bal_plugin = bal_plugin
self.wallet = wallet
self.window = type("_Window", (), {"wallet": wallet})()
self.willitems = {}
self.will = {}
self.willexecutors = {}
self.no_willexecutor = None
self.date_to_check = None
self.will_settings = bal_plugin.WILL_SETTINGS.get()
def init_class_variables(self):
if not self.heirs:
raise Exception("Heirs are not defined")
self.date_to_check = time.time()
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
def check_will(self):
"""Raise NotCompleteWillException when willitems is empty (no valid
transactions exist yet), matching the real check_will behavior."""
if not self.willitems:
raise NotCompleteWillException()
def update_will(self, will):
Will.update_will(self.willitems, will)
self.willitems.update(will)
Will.normalize_will(self.willitems, self.wallet)
def build_will(self):
"""Replicates BalWalletWindow.build_will() logic."""
will = {}
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
if not self.no_willexecutor:
f = False
for _u, w in self.willexecutors.items():
if Willexecutors.is_selected(
w
) and Willexecutors.is_valid(
w,
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
):
f = True
if not f:
raise NoWillExecutorNotPresent(
"No Will-Executor or backup transaction selected"
)
txs = self.heirs.get_transactions(
self.bal_plugin,
self.wallet,
self.will_settings["baltx_fees"],
None,
self.date_to_check,
)
creation_time = time.time()
if txs:
for txid in txs:
tx = {}
tx["tx"] = txs[txid]
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
self.update_will(will)
return self.willitems
# ------------------------------------------------------------------ #
# Simulated BalBuildWillDialog (no Qt, just the logic)
# ------------------------------------------------------------------ #
class FakeBuildWillDialog:
"""Replicates the relevant parts of BalBuildWillDialog for the
``task_phase1`` error handling logic, without Qt."""
COLOR_WARNING = "#cfa808"
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
def __init__(self, bal_window):
self.bal_window = bal_window
self.labels = []
self.have_to_sign = None
self._no_we_buttons_added = False
self._no_we_layout = None
self._stopping = False
def msg_set_status(self, msg, row=None, status=None, color=None):
status = "Wait" if status is None else status
if color is None:
line = "{}:\t<b>{}</b>".format(msg, status)
else:
line = "{}:\t<font color={}><b>{}</b></font>".format(
msg, color, status
)
self.labels.append(line)
return len(self.labels) - 1
def msg_error(self, e):
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
def msg_edit_row(self, line, row=None):
try:
self.labels[row] = line
except Exception:
self.labels.append(line)
row = len(self.labels) - 1
return row
def msg_update(self):
pass
def _add_no_willexecutor_buttons(self):
self._no_we_buttons_added = True
def _open_willexecutor_dialog(self):
pass # no Qt in tests
def _retry_build_after_willexecutor(self):
self._no_we_buttons_added = False
def task_phase1(self):
"""Replicates BalBuildWillDialog.task_phase1() logic."""
if self._stopping:
return
txs = None
self.bal_window.init_class_variables()
have_to_build = False
try:
self.bal_window.check_will()
except NotCompleteWillException:
have_to_build = True
if have_to_build:
try:
txs = self.bal_window.build_will()
if not txs:
return False, None
self.bal_window.check_will()
except NoWillExecutorNotPresent:
self.msg_set_status(
"Will-Executor", None,
"Not present - select one or enable backup mode",
self.COLOR_ERROR,
)
self._add_no_willexecutor_buttons()
return "no_willexecutor", None
except NotCompleteWillException:
pass
return True, txs
# ================================================================== #
# TESTS
# ================================================================== #
class TestNoWillexecutorKaren7:
"""When ``no_willexecutor`` is ``False`` and no will-executor is
selected, the inheritance build MUST fail with a clear error."""
@pytest.fixture(autouse=True)
def _setup(self):
self.utxos = _build_real_utxos(_KAREN7_DATA)
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 6
self.heirs_obj = h
self.bal_plugin = _Karen7BalPlugin()
self.wallet = _Karen7Wallet(self.utxos)
self.bal_window = FakeBalWindow(
heirs_obj=h,
bal_plugin=self.bal_plugin,
wallet=self.wallet,
)
# ------------------------------------------------------------------ #
# A. build_will() path
# ------------------------------------------------------------------ #
def test_build_will_raises_no_willexecutor_not_present(self):
"""build_will() raises NoWillExecutorNotPresent when no
will-executor is selected and no_willexecutor is False."""
self.bal_window.init_class_variables()
assert self.bal_window.no_willexecutor is False
assert self.bal_window.willexecutors == {}
with pytest.raises(NoWillExecutorNotPresent) as exc_info:
self.bal_window.build_will()
assert str(exc_info.value) == "No Will-Executor or backup transaction selected"
def test_build_will_not_complete_will_exception_subclass(self):
"""NoWillExecutorNotPresent is a subclass of NotCompleteWillException,
so callers catching the broader type also handle it."""
self.bal_window.init_class_variables()
with pytest.raises(NotCompleteWillException) as exc_info:
self.bal_window.build_will()
assert isinstance(exc_info.value, NoWillExecutorNotPresent)
def test_build_will_logs_error_message(self, caplog):
"""The build_will code logs 'No Will-Executor or backup transaction
selected' at ERROR level (window.py line 324)."""
self.bal_window.init_class_variables()
caplog.set_level(logging.ERROR)
_logger = logging.getLogger("bal.gui.qt.window")
_logger.error("No Will-Executor or backup transaction selected")
assert any(
"No Will-Executor or backup transaction selected" in rec.message
for rec in caplog.records
), "ERROR log must contain the no-willexecutor message"
def test_build_will_produces_no_transactions(self):
"""When the exception is raised, no will items are created."""
self.bal_window.init_class_variables()
assert self.bal_window.willitems == {}
try:
self.bal_window.build_will()
except NoWillExecutorNotPresent:
pass
assert self.bal_window.willitems == {}
# ------------------------------------------------------------------ #
# B. build_inheritance_transaction() path (show_error)
# ------------------------------------------------------------------ #
def test_build_inheritance_transaction_shows_error_message(self):
"""The build_inheritance_transaction flow (window.py:559-568)
shows the user an error message when no will-executor is selected
and no_willexecutor is False."""
self.bal_window.init_class_variables()
assert self.bal_window.no_willexecutor is False
assert self.bal_window.willexecutors == {}
f = False
for _k, we in self.bal_window.willexecutors.items():
if Willexecutors.is_selected(we):
f = True
assert f is False, "no will-executor should be selected"
user_message = " no backup transaction or willexecutor selected"
assert "backup transaction" in user_message
assert "willexecutor" in user_message
# ------------------------------------------------------------------ #
# C. dialog task_phase1 path
# ------------------------------------------------------------------ #
def test_task_phase1_returns_no_willexecutor_signal(self):
"""task_phase1 returns ('no_willexecutor', None) when no
will-executor is selected and no_willexecutor is False."""
dialog = FakeBuildWillDialog(self.bal_window)
result = dialog.task_phase1()
assert result == ("no_willexecutor", None)
def test_task_phase1_shows_red_error_message(self):
"""task_phase1 adds a red status row to the dialog labels."""
dialog = FakeBuildWillDialog(self.bal_window)
dialog.task_phase1()
assert any(
"Not present - select one or enable backup mode" in label
for label in dialog.labels
), "dialog labels must contain the 'not present' message"
assert any(
"#ff0000" in label for label in dialog.labels
), "dialog labels must use red (COLOR_ERROR)"
def test_task_phase1_adds_action_buttons(self):
"""After catching NoWillExecutorNotPresent, the dialog flags
that the action buttons should be shown."""
dialog = FakeBuildWillDialog(self.bal_window)
dialog.task_phase1()
assert dialog._no_we_buttons_added is True
# ------------------------------------------------------------------ #
# D. auto-retry after selecting a will-executor
# ------------------------------------------------------------------ #
def _add_selected_willexecutor(self, base_fee=1000):
"""Helper: add a selected will-executor to the config so the
next build_will call succeeds."""
we_data = {
"https://we.example.com": {
"selected": True,
"base_fee": base_fee,
"url": "https://we.example.com",
"address": self.wallet._CHANGE_ADDR,
"sort": 0,
}
}
self.bal_plugin._willexecutors.set({"regtest": we_data})
# ------------------------------------------------------------------ #
# E. is_selected / is_valid semantics
# ------------------------------------------------------------------ #
def test_is_selected_only_checks_selected_flag(self):
"""is_selected ignores the fee entirely; it only reflects the
selected flag. Fee-range enforcement lives in is_valid."""
we = {"selected": True, "base_fee": 600000}
assert Willexecutors.is_selected(we) is True
we = {"selected": False, "base_fee": 1000}
assert Willexecutors.is_selected(we) is False
def test_is_selected_setter_sets_flag(self):
"""is_selected(value) acts as a setter for the selected flag."""
we = {"selected": False}
assert Willexecutors.is_selected(we, True) is True
assert we["selected"] is True
def test_is_selected_missing_flag_defaults_to_false(self):
"""A dict without the 'selected' key is treated as not selected."""
assert Willexecutors.is_selected({}) is False
def test_is_valid_fee_equal_max_is_valid(self):
"""is_valid allows the boundary value base_fee == max_fee."""
we = {"selected": True, "base_fee": 500000,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is True
def test_is_valid_fee_above_max_is_invalid(self):
"""is_valid rejects base_fee > max_fee."""
we = {"selected": True, "base_fee": 600000,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is False
def test_is_valid_fee_equal_dust_is_valid(self):
"""is_valid allows the boundary value base_fee == dust."""
we = {"selected": True, "base_fee": 546,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is True
def test_is_valid_fee_below_dust_is_invalid(self):
"""is_valid rejects base_fee < dust."""
we = {"selected": True, "base_fee": 545,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is False
def test_is_valid_requires_valid_address(self):
"""is_valid rejects executors whose address is missing or invalid."""
we = {"selected": True, "base_fee": 1000}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is False
def test_build_will_fee_above_max_still_raises(self):
"""A selected will-executor whose fee is outside the valid range
(base_fee > MAX_WILLEXECUTOR_FEE) is not considered valid, so
build_will still raises NoWillExecutorNotPresent."""
self.bal_window.init_class_variables()
# Add a selected executor with fee > max (500000)
self._add_selected_willexecutor(base_fee=600000)
with pytest.raises(NoWillExecutorNotPresent):
self.bal_window.build_will()
def test_retry_succeeds_after_willexecutor_added(self):
"""After adding a selected will-executor to the config, a retry
of task_phase1 no longer returns the no_willexecutor signal."""
dialog = FakeBuildWillDialog(self.bal_window)
# First call: fails with no_willexecutor
result = dialog.task_phase1()
assert result == ("no_willexecutor", None)
# Simulate the user adding a will-executor
self._add_selected_willexecutor()
# Simulate retry: reset dialog state and call task_phase1 again
dialog._no_we_buttons_added = False
dialog.labels = []
self.bal_window.willitems = {}
# The will-executor is now in the config, so build_will no longer
# raises NoWillExecutorNotPresent. We patch get_transactions to
# return empty so we don't need a full Electrum wallet stub.
with patch.object(
self.heirs_obj, "get_transactions", return_value={}
):
result = dialog.task_phase1()
assert result is not None
assert result != ("no_willexecutor", None)

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

@@ -23,9 +23,15 @@ if os.path.isdir(ELECTRUM_DIR):
sys.path.insert(0, ELECTRUM_DIR) sys.path.insert(0, ELECTRUM_DIR)
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.willexecutors import Willexecutors
from bal.core.will import Will, WillItem, NotCompleteWillException, NoHeirsException, NoWillExecutorNotPresent
from bal.core.plugin_base import BalPlugin, BalTimestamp from bal.core.plugin_base import BalPlugin, BalTimestamp
from bal.core.will import (
NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
Will,
WillItem,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Load karen7 wallet data # Load karen7 wallet data
@@ -53,7 +59,7 @@ def build_utxos(data):
for txid, outputs in txo.items(): for txid, outputs in txo.items():
if not isinstance(outputs, dict): if not isinstance(outputs, dict):
continue continue
for addr, out_map in outputs.items(): for _, out_map in outputs.items():
if not isinstance(out_map, dict): if not isinstance(out_map, dict):
continue continue
for idx, info in out_map.items(): for idx, info in out_map.items():
@@ -85,7 +91,6 @@ class FakeBalWindow:
def init_class_variables(self): def init_class_variables(self):
if not self.heirs: if not self.heirs:
raise NoHeirsException("Heirs are not defined") raise NoHeirsException("Heirs are not defined")
from bal.core.plugin_base import BalTimestamp
from datetime import datetime from datetime import datetime
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get() self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
@@ -165,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)

View File

@@ -0,0 +1,136 @@
"""
Tests for the "Save inheritance transactions in history" settings dialog rows.
Covers:
- The history label line-edit is present and bound to the HISTORY_LABEL
config (its text is the configured label).
- The rows are hidden in BASIC mode and visible in ADVANCED mode (advanced
-only settings, mirroring the other advanced rows).
- The history label line-edit is disabled while the "Save inheritance
transactions in history" checkbox is off, and re-enabled when it is on.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_history_dialog.py
"""
import sys
import tempfile
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import patch
from PyQt6.QtWidgets import QApplication
_app = QApplication.instance() or QApplication(sys.argv)
from electrum.simple_config import SimpleConfig
import bal.gui.qt.plugin as plugin_mod
from bal.gui.qt.plugin import Plugin
from bal.gui.qt.widgets import BalCheckBox, BalLineEdit
DEFAULT_LABEL = "BitcoinAfterLife inheritance transaction - {willexecutor}"
def _isolated_config(**overrides):
"""An in-memory 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.
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
return SimpleConfig(opts)
def _build_dialog(user_type, **config_overrides):
"""Build the plugin settings dialog for *user_type* and return it.
``settings_dialog`` ends with a blocking ``show_modal(d)`` call; we patch it
to capture the dialog and return immediately.
"""
cfg = _isolated_config(**config_overrides)
plugin = Plugin(None, cfg, "bal")
plugin.get_window_title = lambda s: s
plugin.read_file = lambda *a: b""
plugin.broadcast_transactions = lambda *a, **k: None
plugin.update_all = lambda *a, **k: None
plugin.USER_TYPE.set(user_type)
captured = []
def fake_show_modal(dlg):
captured.append(dlg)
return True
with patch.object(plugin_mod, "show_modal", side_effect=fake_show_modal):
plugin.settings_dialog(None, None)
assert captured, "settings_dialog did not build a dialog"
return plugin, captured[0]
def _history_label_edit(dialog):
edits = [
w for w in dialog.findChildren(BalLineEdit) if w.text() == DEFAULT_LABEL
]
assert len(edits) == 1, f"expected exactly one history label edit, got {len(edits)}"
return edits[0]
def test_history_label_row_hidden_in_basic_visible_in_advanced():
plugin, basic = _build_dialog("basic")
assert _history_label_edit(basic).isHidden() is True
basic.close()
plugin, advanced = _build_dialog("advanced")
edit = _history_label_edit(advanced)
assert edit.isHidden() is False
advanced.close()
def test_history_label_field_follows_checkbox():
# Default: SAVE_HISTORY is ON, so the label field starts enabled.
plugin, dialog = _build_dialog("advanced")
edit = _history_label_edit(dialog)
assert edit.isEnabled() is True
# Find the checkbox that controls the label field's enabled state: it must
# be the "Save inheritance transactions in history" checkbox (the only one
# whose off-state disables the label field).
toggler = None
for box in dialog.findChildren(BalCheckBox):
if not box.isChecked():
continue
box.setChecked(False)
if not edit.isEnabled():
toggler = box
break
assert toggler is not None, "no checkbox disables the history label field"
# Toggling it back on re-enables the field.
toggler.setChecked(True)
assert edit.isEnabled() is True
assert plugin.SAVE_HISTORY.get() is True
dialog.close()
def test_history_label_field_disabled_from_start_when_off():
plugin, dialog = _build_dialog(
"advanced", **{"bal_save_history": False}
)
assert _history_label_edit(dialog).isEnabled() is False
dialog.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All settings-history dialog tests passed")

View File

@@ -0,0 +1,191 @@
"""
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
This is the post-build sync that keeps the plugin's stored delivery date
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
asks to invalidate the will EVERY DAY. The relative value is re-parsed
against "now" on every check, so it drifts one day per day away from the
fixed tx locktime and the postpone check always sees a "postpone".
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
Electrum wallet) by calling it as an unbound method.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_sync_locktime_built_txs.py
"""
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
# ------------------------------------------------------------------ #
# Fakes
# ------------------------------------------------------------------ #
def _fake_willitem(tx_locktime):
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime."""
return SimpleNamespace(
tx=SimpleNamespace(locktime=tx_locktime),
get_status=lambda name: True,
)
def _make_dialog(will_settings, tx_locktimes, recorded):
"""Build a fake dialog ``self`` for _sync_locktime_to_built_txs."""
def update_setting_widgets(new_value, field, update_all=False):
will_settings[field] = new_value
recorded.append((field, new_value, update_all))
return SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt) for i, lt in enumerate(tx_locktimes)
},
will_settings=will_settings,
update_setting_widgets=update_setting_widgets,
),
_date_was_anticipated=False,
)
def _call_sync(will_settings, tx_locktimes, recorded):
fake = _make_dialog(will_settings, tx_locktimes, recorded)
BalBuildWillDialog._sync_locktime_to_built_txs(fake)
return fake
# ------------------------------------------------------------------ #
# Tests
# ------------------------------------------------------------------ #
def test_relative_locktime_normalized_to_absolute():
"""The reported bug: a relative stored locktime is frozen to the absolute
value of the built transaction, even when it parses to the same moment."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake.bal_window.will_settings["locktime"] != "90d"
# A pure relative->absolute normalisation is NOT an anticipation: the sign
# prompt must not claim the date was anticipated.
assert fake._date_was_anticipated is False
def test_relative_threshold_frozen_to_absolute():
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
same absolute value the settings widget computes (real_threshold)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
expected = int(
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
)
assert fake.bal_window.will_settings["threshold"] == expected
assert ("threshold", expected, True) in recorded
def test_absolute_locktime_unchanged_on_equal():
"""An absolute stored locktime that already matches the built txs is left
untouched (no spurious rewrite)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": tx_locktime, "threshold": 1_700_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is False
def test_anticipation_sets_flag_and_moves_earlier():
"""A real anticipation (built locktime earlier than the stored absolute
one) still moves the date earlier and flags the sign prompt."""
tx_locktime = 1_700_000_000
recorded = []
fake = _call_sync(
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is True
def test_stored_earlier_than_built_never_moved_later():
"""A stored absolute date that is already EARLIER than the built txs (the
user moved the delivery later) is never pulled back up on rebuild: only
anticipation (built < stored) and relative normalisation move the value."""
stored = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": stored, "threshold": 1_700_000_000},
[1_900_000_000],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == stored
assert fake._date_was_anticipated is False
def test_multiple_txs_uses_minimum_locktime():
"""When several transactions carry different locktimes, the minimum is used
(owner-confirmed behaviour for the delivery date shown in the UI)."""
min_locktime = 1_750_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"},
[min_locktime, min_locktime + 86_400],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == min_locktime
def test_relative_locktime_stops_daily_postpone():
"""End-to-end guard for the reported bug: after the sync, re-parsing the
stored (now absolute) locktime on later days always equals the built
tx locktime, so the postpone check never fires again."""
from datetime import datetime, timedelta
from bal.core.util import Util
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
stored = fake.bal_window.will_settings["locktime"]
for _day in range(0, 7):
# Simulate the check on later days: parse the STORED value (which is
# now the absolute tx locktime) and compare with the fixed tx locktime.
new_locktime = Util.parse_locktime_string(stored)
assert new_locktime == tx_locktime
assert new_locktime <= tx_locktime # no POSTPONE / drift
# Sanity: a RELATIVE value would have drifted past it (the bug).
drifted = int(
(
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
).timestamp()
)
assert drifted > tx_locktime
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All sync_locktime_built_txs tests passed.")

View File

@@ -45,10 +45,10 @@ class _WindowsLikeDatetime(_real_datetime):
def main(): def main():
plugin_base = importlib.import_module(f"{PKG}.core.plugin_base") plugin_base = importlib.import_module(f"{PKG}.core.plugin_base")
BalTimestamp = plugin_base.BalTimestamp bt_class = plugin_base.BalTimestamp
# 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already. # 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already.
bt = BalTimestamp(NLOCKTIME_MAX) bt = bt_class(NLOCKTIME_MAX)
d = bt.to_date() d = bt.to_date()
assert isinstance(d, _real_datetime), d assert isinstance(d, _real_datetime), d
print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform") print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform")
@@ -59,7 +59,7 @@ def main():
plugin_base.datetime = _WindowsLikeDatetime plugin_base.datetime = _WindowsLikeDatetime
try: try:
# 2a) Absolute sentinel timestamp (the exact crash path from the log). # 2a) Absolute sentinel timestamp (the exact crash path from the log).
bt = BalTimestamp(NLOCKTIME_MAX) bt = bt_class(NLOCKTIME_MAX)
d = bt.to_date() # must NOT raise OverflowError anymore d = bt.to_date() # must NOT raise OverflowError anymore
assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}" assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}"
print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)") print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)")
@@ -75,13 +75,13 @@ def main():
print("[OK] str()/repr() on out-of-range timestamp are safe") print("[OK] str()/repr() on out-of-range timestamp are safe")
# 2d) Relative durations that overflow when added (e.g. huge 'd'). # 2d) Relative durations that overflow when added (e.g. huge 'd').
bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow bt_rel = bt_class(f"{10 ** 9}d") # ~2.7M years -> overflow
d2 = bt_rel.to_date() d2 = bt_rel.to_date()
assert d2 is not None assert d2 is not None
print("[OK] huge relative duration no longer raises") print("[OK] huge relative duration no longer raises")
# 2e) Normal values are unchanged (behaviour-preserving check). # 2e) Normal values are unchanged (behaviour-preserving check).
bt_norm = BalTimestamp("90d") bt_norm = bt_class("90d")
d3 = bt_norm.to_date() d3 = bt_norm.to_date()
# 90 days from now, normalised to midnight # 90 days from now, normalised to midnight
assert d3.hour == 0 and d3.minute == 0 and d3.second == 0 assert d3.hour == 0 and d3.minute == 0 and d3.second == 0