Compare commits
24 Commits
v0.6.1
...
9c4697c923
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c4697c923 | |||
| 42f05d3c4f | |||
| 3a9ee5adb9 | |||
| 2c9f6bdd9d | |||
| 4dfb3fc41c | |||
| 1ae1617172 | |||
| 125bf09b9a | |||
| b5752a42f0 | |||
| ce659048ca | |||
| c80f0e1524 | |||
| e8e4d5e451 | |||
| e74c348bbb | |||
| fdcab7c6bd | |||
| eac03da530 | |||
| 167d1228b2 | |||
| 8649556a18 | |||
| 5e53a87bb6 | |||
| 251fd93cbd | |||
| bdeef3dc6a | |||
| 6f7a364bc6 | |||
| a1b13947c3 | |||
| 6e0ae7e4ed | |||
| 9275e9c6a2 | |||
| 658e3dca0b |
29
.gitignore
vendored
29
.gitignore
vendored
@@ -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
94
AGENTS.md
Normal 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).
|
||||||
483
CHANGELOG.md
483
CHANGELOG.md
@@ -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.
|
||||||
|
|||||||
175
HANDOFF.md
175
HANDOFF.md
@@ -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
415
PLAN_CMDLINE_PLUGIN.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# Piano: supporto da riga di comando (CLI) per il plugin BAL
|
||||||
|
|
||||||
|
> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato.
|
||||||
|
>
|
||||||
|
> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Obiettivo
|
||||||
|
|
||||||
|
Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon
|
||||||
|
di Electrum, senza GUI Qt, esponendo comandi per:
|
||||||
|
|
||||||
|
1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista.
|
||||||
|
2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export.
|
||||||
|
3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default.
|
||||||
|
4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx).
|
||||||
|
|
||||||
|
Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è
|
||||||
|
già GUI-free) e senza importare mai PyQt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stato attuale (verificato sul codice)
|
||||||
|
|
||||||
|
### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`)
|
||||||
|
|
||||||
|
Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
|
||||||
|
`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin:
|
||||||
|
|
||||||
|
- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in
|
||||||
|
`electrum/commands.py:2317`. Un comando plugin:
|
||||||
|
- è **sempre** un `async def`;
|
||||||
|
- viene registrato come `bal_<nome_funzione>` su `Commands` (quindi anche nel parser CLI);
|
||||||
|
- **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`;
|
||||||
|
- alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337).
|
||||||
|
- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`).
|
||||||
|
- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`.
|
||||||
|
- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.<gui_name>` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`.
|
||||||
|
- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag:
|
||||||
|
- `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`);
|
||||||
|
- `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma.
|
||||||
|
- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1.
|
||||||
|
|
||||||
|
### 2.2 Il plugin (bal v0.6.1)
|
||||||
|
|
||||||
|
- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile:
|
||||||
|
- `heirs.py` — `Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`.
|
||||||
|
- `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
|
||||||
|
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
|
||||||
|
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
|
||||||
|
- `checkalive.py` — `resolve_date_to_check`, `check_alive_expired` (riferimento temporale unico per ogni check).
|
||||||
|
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
|
||||||
|
- `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt):
|
||||||
|
- `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
|
||||||
|
- `init_class_variables` (618) e `build_will` (397),
|
||||||
|
- `build_inheritance_transaction` (678) → il flusso completo "prepara will",
|
||||||
|
- `sign_transactions` (952), `ask_password_and_sign_transactions` (1084),
|
||||||
|
- `push_transactions_to_willexecutors` (1164), `broadcast_transactions` (1127),
|
||||||
|
- `check_transactions_task`/`check_transactions` (1414/1464),
|
||||||
|
- `export_json_file` (1246), `merge_will` (1264), `merge_will_from_file` (1348), `_load_will_file` (1406),
|
||||||
|
- `invalidate_will` (917).
|
||||||
|
- `bal/manifest.json`: `"available_for": ["qt"]`, `"version": "0.6.1"`.
|
||||||
|
- `build_zip.py`: cammina ricorsivamente su `bal/` (esclude `__pycache__`, `.pyc`), quindi **includerà automaticamente** i nuovi file di `bal/cli/` e `bal/cmdline.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architettura proposta
|
||||||
|
|
||||||
|
```
|
||||||
|
bal/
|
||||||
|
__init__.py # MODIFICATO: importa ``from .cli import commands`` (registra i comandi)
|
||||||
|
cmdline.py # NUOVO: shim zip-safe (come qt.py) che ri-espone Plugin da bal.cli.plugin
|
||||||
|
cli/
|
||||||
|
__init__.py # NUOVO
|
||||||
|
commands.py # NUOVO: tutti i @plugin_command (async), sottili, delegano al controller
|
||||||
|
controller.py # NUOVO: BalController — facciata headless per-wallet (replica di BalWindow senza Qt)
|
||||||
|
plugin.py # NUOVO: class Plugin(BalPlugin) — entry-point per il daemon (gui_name='cmdline')
|
||||||
|
manifest.json # MODIFICATO: available_for = ["qt", "cmdline"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Principi:
|
||||||
|
|
||||||
|
- **`bal/cli/` non importa mai Qt** (stessa regola di `bal/core/`). Può importare solo `bal.core`, `electrum.*` e stdlib.
|
||||||
|
- **`commands.py` = livello di trasporto**: firma `async def bal_x(self, wallet=None, plugin=None, ...)`, valida/parsa argomenti, chiama il controller, ritorna strutture JSON-serializzabili. Zero logica di business.
|
||||||
|
- **`controller.py` = il cuore**: replica i passi GUI-free di `BalWindow`, ma con errori espressi come eccezioni (i messaggi GUI `show_message`/`show_error` diventano raise/ritorni), e persiste esplicitamente su wallet DB.
|
||||||
|
- **`plugin.py`** è quasi vuoto: eredita `BalPlugin.__init__` e basta (serve solo perché Electrum istanzi `module.Plugin(self, config, name)`).
|
||||||
|
- **Nessuna dipendenza nuova** richiesta: `aiohttp`, `dns` e il resto sono già usati da `bal/core`.
|
||||||
|
|
||||||
|
### 3.1 Perché i comandi richiedono il daemon
|
||||||
|
|
||||||
|
`plugin_command` forza il flag `'n'` in `commands.py:2321-2322`. Conseguenza
|
||||||
|
architetturale da documentare chiaramente:
|
||||||
|
|
||||||
|
```
|
||||||
|
electrum daemon -d # avvia il daemon (rete + plugin cmdline)
|
||||||
|
electrum load_wallet # carica/sblocca il wallet
|
||||||
|
electrum bal_heirs_list # i comandi BAL girano contro il daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Questa è la stessa limitazione di tutti gli altri plugin con comandi CLI
|
||||||
|
(es. `swapserver`, `nwc`). Non è aggirabile senza hackare `plugin_command`, che
|
||||||
|
escludiamo dal piano.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modifiche ai file esistenti
|
||||||
|
|
||||||
|
### 4.1 `bal/manifest.json`
|
||||||
|
- `"available_for": ["qt", "cmdline"]`.
|
||||||
|
|
||||||
|
Nessun cambio di versione necessario per lo sviluppo; la versione si alzerà in
|
||||||
|
`make-release.sh` come già avviene.
|
||||||
|
|
||||||
|
### 4.2 `bal/__init__.py`
|
||||||
|
- Aggiungere in fondo:
|
||||||
|
```python
|
||||||
|
# Registra i comandi CLI (bal_*) appena Electrum importa il pacchetto,
|
||||||
|
# sia in modalità cmd_only (pre-parse) sia nel daemon.
|
||||||
|
from . import cli # noqa: F401 (importa bal.cli.commands, che registra i @plugin_command)
|
||||||
|
```
|
||||||
|
(oppure `from .cli import commands` esplicito).
|
||||||
|
- Accortezza: `bal/cli/commands.py` deve essere importabile **senza Qt** e senza
|
||||||
|
effetti collaterali pesanti, perché viene importato anche nel pre-parse CLI e
|
||||||
|
all'avvio della GUI.
|
||||||
|
|
||||||
|
### 4.3 `build_zip.py`
|
||||||
|
- Nessuna modifica obbligatoria: il walker include già `cli/` e `cmdline.py`.
|
||||||
|
- **Opzionale (consigliato)**: aggiungere una stampa di avviso quando l'archivio
|
||||||
|
contiene sia `cmdline.py` che `qt.py`, e verificare che `manifest.json` abbia
|
||||||
|
entrambi i valori in `available_for`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nuovi file
|
||||||
|
|
||||||
|
### 5.1 `bal/cmdline.py` (shim, ~stesso schema di `qt.py`)
|
||||||
|
|
||||||
|
Riproduce il pattern zip-safe di `qt.py` (creazione dei package intermedi in
|
||||||
|
`sys.modules`, import via `importlib.import_module`), ma punta a
|
||||||
|
`bal.cli.plugin`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Plugin = _plugin_module.Plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `bal/cli/plugin.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Plugin(BalPlugin):
|
||||||
|
def __init__(self, parent, config, name):
|
||||||
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
|
```
|
||||||
|
|
||||||
|
Niente hook Qt, niente `bal_windows`. Il daemon lo istanzia quando
|
||||||
|
`get_plugin('bal')` viene chiamato dal wrapper di `plugin_command`.
|
||||||
|
|
||||||
|
### 5.3 `bal/cli/controller.py` — `BalController`
|
||||||
|
|
||||||
|
Facciata per-wallet che incapsula lo stato e i flussi. Attributi (speculari a
|
||||||
|
`BalWindow`):
|
||||||
|
- `plugin` (il `BalPlugin`/`Plugin` iniettato),
|
||||||
|
- `wallet` (iniettato da Electrum),
|
||||||
|
- `will_settings` (da `plugin.WILL_SETTINGS.get()` + `Util.fix_will_settings_tx_fees`),
|
||||||
|
- `heirs` (`Heirs(wallet)` validati),
|
||||||
|
- `willexecutors` (`Willexecutors.get_willexecutors(plugin)`),
|
||||||
|
- `willitems` (da `wallet.db.get_dict("will")` → `WillItem(w, wallet=wallet)`),
|
||||||
|
- `date_to_check` (via `resolve_date_to_check`).
|
||||||
|
|
||||||
|
Metodi principali (replicano le funzioni Qt, senza dialoghi):
|
||||||
|
|
||||||
|
| Metodo | Replica di (`window.py`) | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
|
||||||
|
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
|
||||||
|
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `check_alive_expired`. |
|
||||||
|
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
|
||||||
|
| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. |
|
||||||
|
| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. |
|
||||||
|
| `push_transactions_to_willexecutors(force)` | 1164 | `get_willexecutor_transactions` + `push_transactions_parallel` + gestione "already present" con `check_transaction`. Aggiorna `PUSHED/PUSH_FAIL`. |
|
||||||
|
| `check_transactions()` | 1414 | `check_transactions_parallel` + `set_check_willexecutor(res)` per item. |
|
||||||
|
| `export_json_file(path)` | 1246 | `write_json_file(path, {wid: wi.to_dict()...})` con `tx` come stringa (formato identico a `_load_will_file`). |
|
||||||
|
| `merge_will_from_file(path)` | 1348 | `_load_will_file` + `merge_will` (stessa semantica di `window.py:1264`). |
|
||||||
|
| `_load_will_file(path)` | 1406 | `read_json_file` + `tx_from_any` + `WillItem`. |
|
||||||
|
| `invalidate_will()` | 917 | `Will.invalidate_will(...)` con `history_label` e `will_locktime`. |
|
||||||
|
| `fetch_will_executors_list()` / `ping()` | 1491/1771 | `download_list(old, welist_server)` + `ping_servers_parallel`, poi `Willexecutors.save(plugin, ...)`. |
|
||||||
|
| `apply_settings(cfg_name, value)` | — | Mappa il nome chiave all'attributo `BalConfig` del plugin e fa `set(...)`. |
|
||||||
|
|
||||||
|
Regole di persistenza (fondamentali):
|
||||||
|
- **heirs** → `heirs.save()` (via `__setitem__`/`pop` già implementati) + `wallet.save_db()`.
|
||||||
|
- **will** → `save_willitems()` + `wallet.save_db()`.
|
||||||
|
- **willexecutors** → `Willexecutors.save(plugin, willexecutors)` (config, non wallet DB).
|
||||||
|
- **settings** → `BalConfig.set(...)` (config).
|
||||||
|
|
||||||
|
### 5.4 `bal/cli/commands.py` — comandi (tutti `async def` + `@plugin_command`)
|
||||||
|
|
||||||
|
Firma standard: `async def bal_x(self, wallet=None, plugin=None, ...)`. Flag:
|
||||||
|
- `'n'` — imposto automaticamente da `plugin_command` (rete/daemon).
|
||||||
|
- `'w'` — wallet richiesto e iniettato da Electrum.
|
||||||
|
- `'p'` — solo per i comandi che firmano (richiede `--password`).
|
||||||
|
|
||||||
|
Tutti i comandi costruiscono `controller = BalController(plugin, wallet)` e
|
||||||
|
ritornano strutture JSON-serializzabili. Elenco completo al §6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tabella comandi
|
||||||
|
|
||||||
|
Convenzioni:
|
||||||
|
- `<WALLET>`: wallet caricato nel daemon (non serve passarlo; Electrum usa quello
|
||||||
|
configurato o `--wallet`).
|
||||||
|
- Output: `list`/`dict` stampati come JSON; exit 0 su successo, 1 su errore.
|
||||||
|
- `*` = richiede password (`--password`) se il wallet è cifrato.
|
||||||
|
|
||||||
|
### 6.1 Willexecutors
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_willexecutors_list` | `nw` | — | Elenco `{url: {address, base_fee, status, info, selected, last_update, sort}}` per la chain corrente. |
|
||||||
|
| `bal_willexecutors_show` | `nw` | `url` | Dettaglio di un singolo will-executor. |
|
||||||
|
| `bal_willexecutors_add` | `nw` | `url` `address` `base_fee` | Aggiunge/aggiorna un will-executor (via `initialize_willexecutor`), `selected=false` di default. Ritorna il record. |
|
||||||
|
| `bal_willexecutors_update` | `nw` | `url` `[address]` `[base_fee]` `[info]` `[promo_code]` | Modifica i campi indicati e salva. |
|
||||||
|
| `bal_willexecutors_select` | `nw` | `url` `value` | `is_selected(we, eval_bool(value))` + salva. |
|
||||||
|
| `bal_willexecutors_delete` | `nw` | `url` | Rimuove dalla lista e salva. |
|
||||||
|
| `bal_willexecutors_ping` | `nw` | `[url]` | `ping_servers_parallel` (tutti o uno); aggiorna `status/base_fee/address`; salva. Output: risultati per url. |
|
||||||
|
| `bal_willexecutors_download` | `nw` | — | `download_list(old, plugin.WELIST_SERVER.get())`; unisce e salva. Output: n. record. |
|
||||||
|
| `bal_willexecutors_import` | `nw` | `path` | Legge un JSON `{url: record}` (stesso formato di export), `initialize_willexecutor` per record, salva. |
|
||||||
|
| `bal_willexecutors_export` | `nw` | `path` | Scrive `{url: record}` su file JSON. |
|
||||||
|
|
||||||
|
### 6.2 Heirs
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_heirs_list` | `nw` | — | `{name: [address, amount, locktime, ...]}` (tutte le colonne `HEIR_*`). |
|
||||||
|
| `bal_heirs_show` | `nw` | `name` | Dettaglio di un singolo heir. |
|
||||||
|
| `bal_heirs_add` | `nw` | `name` `address` `amount` `locktime` | Valida con `Heirs.validate_heir` (OP_RETURN incluso) e salva. `amount` può essere satoshi o `"50%"`. `locktime` può essere timestamp assoluto o relativo `"30d"`/`"1y"`. |
|
||||||
|
| `bal_heirs_update` | `nw` | `name` `[address]` `[amount]` `[locktime]` | Modifica i campi indicati (ri-validazione) e salva. |
|
||||||
|
| `bal_heirs_delete` | `nw` | `name` | `heirs.pop(name)` + `save_db()`. |
|
||||||
|
| `bal_heirs_import` | `nw` | `path` | `Heirs.import_file(path)` (validazione + merge). |
|
||||||
|
| `bal_heirs_export` | `nw` | `path` | `Heirs.export_file(path)`. |
|
||||||
|
|
||||||
|
### 6.3 Impostazioni
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_settings_list` | `n` | — | Elenco di tutte le `BalConfig` del plugin: `{chiave: {value, default, name}}` (nome leggibile). |
|
||||||
|
| `bal_settings_get` | `n` | `key` | Valore corrente di una chiave (`bal_*`). |
|
||||||
|
| `bal_settings_set` | `n` | `key=value` | Scrive il valore (conversione di tipo: bool/int/str/JSON) via `BalConfig.set(...)`. `bal_will_settings` accetta JSON. |
|
||||||
|
| `bal_settings_reset` | `n` | `key` | `BalConfig.set(cfg.default)`. |
|
||||||
|
|
||||||
|
### 6.4 Will
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_will_status` | `nw` | — | Per ogni `wid` (txid): locktime, `heirsvalue`, executor, flag di stato (`VALID/COMPLETE/PUSHED/CHECKED/CHECK_FAIL/...`), `sigs_have/sigs_required`, `tx_fees`, executor URL. |
|
||||||
|
| `bal_will_check` | `nw` | — | `check_will()` (coerenza heirs+executor+fees+locktime, in locale). Ritorna `{"valid": true}` o un errore esplicito (es. `HeirNotFound`, `WillPostponed`, `WillExpired`, `NoHeirs`). |
|
||||||
|
| `bal_will_prepare` | `nw` | — | Flusso completo `build_inheritance_transaction`: check → rebuild se non coerente → persiste. Output: riepilogo tx nuova/aggiornata per wid. |
|
||||||
|
| `bal_will_sign` | `nwp` | `[txid]` | Firma i `VALID` non completi (o solo `txid`). Aggiorna `COMPLETE` e `sigs_*`; persiste. Output per txid. |
|
||||||
|
| `bal_will_broadcast` | `nw` | `[txid]` `force` | `push_transactions_to_willexecutors(force, txids)` parallelo; aggiorna `PUSHED/PUSH_FAIL`. Output: `{url: status}`. |
|
||||||
|
| `bal_will_export` | `nw` | `path` | `export_json_file(path)`. |
|
||||||
|
| `bal_will_import_merge` | `nw` | `path` | `merge_will_from_file(path)` (stessa semantica GUI: merge psbt/stati, mai perdere una tx viva). |
|
||||||
|
| `bal_will_invalidate` | `nw` | — | `Will.invalidate_will(...)`; ritorna la tx di invalidazione (da firmare+trasmettere con i comandi sopra). |
|
||||||
|
| `bal_will_check_executor` | `nw` | `[txid]` | Verifica lato will-executor: `check_transactions_parallel` (searchtx) per i `VALID+PUSHED` non `CHECKED`; applica `set_check_willexecutor`. Output: `{wid: {url, checked, ok}}`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Flusso dati e persistenza
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI (electrum bal_*) Daemon (Electrum 4.8.0)
|
||||||
|
┌───────────────────────┐ ┌──────────────────────────────────────┐
|
||||||
|
│ run_electrum │ RPC │ Daemon.run_cmdline │
|
||||||
|
│ pre-parse cmd_only │ ─────────────► │ plugin_command wrapper │
|
||||||
|
│ -> importa bal │ jsonrpc │ inietta plugin + wallet │
|
||||||
|
│ (registra bal_*) │ │ bal/cli/commands.py │
|
||||||
|
└───────────────────────┘ │ -> BalController(plugin, wallet) │
|
||||||
|
│ -> bal.core.* │
|
||||||
|
│ -> wallet.db / config (persist) │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Lettura**: `wallet.db.get_dict("will")` (wills), `Heirs(wallet)` (heirs),
|
||||||
|
`plugin.WILLEXECUTORS.get()`/`plugin.WILL_SETTINGS.get()` (config).
|
||||||
|
- **Scrittura**: `save_willitems()` → `wallet.db` + `wallet.save_db()`;
|
||||||
|
`heirs.save()`; `Willexecutors.save(...)`; `BalConfig.set(...)`.
|
||||||
|
- **Firma**: `wallet.sign_transaction(tx, password, ignore_warnings=True)` —
|
||||||
|
idem GUI, quindi compatibile con multisig e wallet cifrati (password via `--password`).
|
||||||
|
- **Rete**: `Network.get_instance()` già usato da `bal/core/willexecutors.py`
|
||||||
|
(i comandi `'n'` garantiscono rete attiva).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Errori, exit code, output
|
||||||
|
|
||||||
|
- Ritorno `None` → nessun output; `str` → stampato; `dict`/`list` → `json_encode`.
|
||||||
|
- Errori utente: sollevare `electrum.util.UserFacingException(msg)` → in modalità
|
||||||
|
daemon viene stampato `msg` con exit 1.
|
||||||
|
- Errori di dominio BAL (`WillExpiredException`, `WillPostponedException`,
|
||||||
|
`HeirNotFoundException`, `NoWillExecutorNotPresent`, `CheckAliveError`,
|
||||||
|
`AmountException`, ...): il controller le converte in `UserFacingException`
|
||||||
|
con testo in chiaro (riuso dei messaggi già presenti, senza HTML/Qt).
|
||||||
|
- Convenzione consigliata per comandi che producono più di un risultato:
|
||||||
|
ritornare un `dict` con chiave `"result"`/`"warnings"` quando servono avvisi
|
||||||
|
(es. dopo `prepare` con heirs scartati per dust).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Compatibilità Electrum 4.7.2 / 4.8.0
|
||||||
|
|
||||||
|
- `plugin_command`, il wrapper `@command` e `daemon._plugins.get_plugin` esistono
|
||||||
|
in entrambe le versioni (verificati su 4.8.0; usati identici da `swapserver`).
|
||||||
|
- Il `BalPlugin` già gestisce il cambio API di registrazione dict
|
||||||
|
(`json_db.register_dict` vs `stored_dict.register_name`): nessun intervento.
|
||||||
|
- `available_for: ["cmdline"]` è lo stesso meccanismo di `trustedcoin`
|
||||||
|
(che ha già `cmdline.py` in 4.8.0).
|
||||||
|
- **Nessun nuovo import Qt** in `bal/cli/`: verificabile in CI con un check
|
||||||
|
statico su `bal/cli/*.py` e `bal/cmdline.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Build / release
|
||||||
|
|
||||||
|
- `python3 build_zip.py` produce `bal-electrum-plugin.zip` con `cli/`, `cmdline.py`
|
||||||
|
e il manifest aggiornato. Lo zip serve sia per la GUI che per il daemon.
|
||||||
|
- Il test `external_zip_test.py` andrà esteso (vedi §11) per verificare che il
|
||||||
|
zip, caricato da Electrum, registri anche i comandi `bal_*`.
|
||||||
|
- Nessun cambiamento a `make-release.sh` (la versione resta nel manifest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Piano di test e verifica
|
||||||
|
|
||||||
|
### 11.1 Nuovi test standalone (stile repo: `tests/test_*.py` con `if __name__ == "__main__"`)
|
||||||
|
|
||||||
|
- `tests/test_cli_commands_registered.py` (runtime env):
|
||||||
|
- importa `electrum.plugins.bal` con `Plugins(config, cmd_only=True)`;
|
||||||
|
- asserisce che `known_commands` contenga tutti i nomi `bal_*` della tabella;
|
||||||
|
- asserisce che ogni funzione sia coroutine e abbia il flag `n`.
|
||||||
|
- `tests/test_cli_controller.py` (runtime env, offline, senza rete):
|
||||||
|
- wallet "fake"/temporaneo (pattern di `test_core_heirs.py`);
|
||||||
|
- CRUD heirs e willexecutors, settings get/set/reset, export/import will
|
||||||
|
(merge), build will con fixtures note.
|
||||||
|
- `tests/test_cli_zip.py` (o estensione di `external_zip_test.py`):
|
||||||
|
- costruisce lo zip, lo carica come `electrum_external_plugins.bal` con
|
||||||
|
`Plugins(config, 'cmdline')`, asserisce `available_for` include `"cmdline"`
|
||||||
|
e che `get_plugin('bal')` restituisca il `Plugin` di `bal.cli.plugin`
|
||||||
|
(nessun import Qt eseguito).
|
||||||
|
- `tests/test_cli_will_flows.py` (offline, dove possibile):
|
||||||
|
- prepare → sign → export → merge su un wallet di test con heirs fissi;
|
||||||
|
- verifica che `wallet.db.get_dict("will")` rifletta COMPLETE/PUSHED dopo
|
||||||
|
le operazioni che non toccano rete.
|
||||||
|
|
||||||
|
### 11.2 Verifica manuale (da documentare nel README/HANDOFF)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
electrum daemon -d
|
||||||
|
electrum load_wallet
|
||||||
|
electrum bal_heirs_list
|
||||||
|
electrum bal_settings_list
|
||||||
|
electrum bal_will_status
|
||||||
|
electrum bal_will_prepare
|
||||||
|
electrum bal_will_sign --password '...' # se wallet cifrato
|
||||||
|
electrum bal_will_broadcast
|
||||||
|
electrum bal_will_check_executor
|
||||||
|
electrum bal_willexecutors_ping
|
||||||
|
electrum stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Regressione
|
||||||
|
|
||||||
|
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
|
||||||
|
deve continuare a passare (prova che `bal/__init__` + Qt convivono con il
|
||||||
|
nuovo import di `bal.cli.commands`).
|
||||||
|
- Eseguire i `test_core_*.py` esistenti (nessuna logica core toccata).
|
||||||
|
- Ruff: evitare nuove violazioni in `bal/cli/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Rischi e decisioni aperte
|
||||||
|
|
||||||
|
1. **Daemon obbligatorio** (non `--offline`): imposto da `plugin_command`.
|
||||||
|
→ Accettato; documentato al §3.1.
|
||||||
|
2. **Wallet pre-caricato**: i comandi `w` falliscono con "wallet not loaded" se
|
||||||
|
non si lancia prima `electrum load_wallet`. → Documentare.
|
||||||
|
3. **`bal/__init__.py` che importa `bal.cli.commands`**: viene eseguito anche
|
||||||
|
all'avvio della GUI. `commands.py` deve restare leggero (solo definizioni +
|
||||||
|
import di `electrum.commands` e `bal.core`). Da verificare con `smoke_test.py`.
|
||||||
|
4. **Doppio caricamento**: se un install è contemporaneamente interno E zip
|
||||||
|
esterno, la seconda importazione di `commands.py` potrebbe sollevare
|
||||||
|
"Command name bal_... already exists". Pratica corrente: un solo install;
|
||||||
|
si può mitigare con un guard `if not getattr(module, '_registered')`.
|
||||||
|
5. **OP_RETURN heirs** in CLI: gestiti come in GUI (`validate_op_return_hex`,
|
||||||
|
colonne quantità `"0"`). Da testare.
|
||||||
|
6. **Persistenza `will_settings`**: oggi letta dalla config globale
|
||||||
|
(`bal_will_settings`) in `BalWindow.__init__`, non dal wallet DB. Il
|
||||||
|
controller deve replicare esattamente questo (config), non introdurre una
|
||||||
|
seconda sorgente.
|
||||||
|
7. **Multisig**: la firma usa `wallet.sign_transaction` → supportata; il flusso
|
||||||
|
"merge PSBT" copre la firma parziale. Test dedicato con wallet multisig in
|
||||||
|
fase di implementazione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Fasi di implementazione (ordine proposto)
|
||||||
|
|
||||||
|
1. `bal/cli/__init__.py`, `bal/cli/plugin.py`, `bal/cmdline.py`, update
|
||||||
|
`bal/manifest.json` + `bal/__init__.py`.
|
||||||
|
2. `tests/test_cli_commands_registered.py` + verifica `smoke_test.py`.
|
||||||
|
3. `bal/cli/controller.py` (read-only: status/list/show) → `commands.py` per
|
||||||
|
willexecutors/heirs/settings (senza rete).
|
||||||
|
4. Comandi will: `prepare`, `sign`, `export`, `import_merge`, `invalidate`.
|
||||||
|
5. Comandi di rete: `ping`, `download`, `broadcast`, `check_executor`.
|
||||||
|
6. Test zip (`test_cli_zip.py`), estensione `external_zip_test.py`, prova
|
||||||
|
manuale col daemon, aggiornamento README/HANDOFF.
|
||||||
84
README.md
84
README.md
@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
|
|||||||
that transfer your funds to your heirs if you stop refreshing them
|
that transfer your funds to your heirs if you stop refreshing them
|
||||||
(dead-man's switch), optionally relayed by will-executor servers.
|
(dead-man's switch), optionally relayed by will-executor servers.
|
||||||
|
|
||||||
This repository contains a **behavior-preserving refactor** of the original
|
This repository contains a **refactored and extended** version of the original
|
||||||
plugin. The logic was kept byte-identical wherever possible; only the file
|
plugin. The logic was reorganized to cleanly separate **business logic** from the
|
||||||
layout was reorganized to cleanly separate **business logic** from the
|
**PyQt GUI**, and new features have been added including a headless CLI,
|
||||||
**PyQt GUI**.
|
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
|
||||||
|
reminders.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -16,12 +17,20 @@ layout was reorganized to cleanly separate **business logic** from the
|
|||||||
bal/ the installable Electrum plugin package
|
bal/ the installable Electrum plugin package
|
||||||
├── manifest.json plugin metadata (Electrum reads this)
|
├── manifest.json plugin metadata (Electrum reads this)
|
||||||
├── qt.py Qt entry-point shim (re-exports Plugin)
|
├── qt.py Qt entry-point shim (re-exports Plugin)
|
||||||
|
├── cmdline.py CLI entry-point shim (re-exports Plugin)
|
||||||
├── core/ GUI-free logic (importable without Qt)
|
├── core/ GUI-free logic (importable without Qt)
|
||||||
│ ├── util.py
|
│ ├── util.py
|
||||||
│ ├── plugin_base.py
|
│ ├── plugin_base.py
|
||||||
│ ├── heirs.py
|
│ ├── heirs.py
|
||||||
│ ├── will.py
|
│ ├── will.py
|
||||||
│ └── willexecutors.py
|
│ ├── willexecutors.py
|
||||||
|
│ ├── checkalive.py
|
||||||
|
│ ├── reminders.py
|
||||||
|
│ └── input_rules.py
|
||||||
|
├── cli/ headless command-line layer (no Qt)
|
||||||
|
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
||||||
|
│ ├── controller.py headless BalController (replicates BalWindow)
|
||||||
|
│ └── plugin.py CLI Plugin entry point
|
||||||
├── gui/qt/ PyQt6 presentation layer
|
├── gui/qt/ PyQt6 presentation layer
|
||||||
│ ├── theme.py status → color mapping
|
│ ├── theme.py status → color mapping
|
||||||
│ ├── common.py shared imports / helpers
|
│ ├── common.py shared imports / helpers
|
||||||
@@ -30,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
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -24,11 +24,18 @@ distinct sub-packages:
|
|||||||
lists.py Tree/list views (heirs, preview, will-executors)
|
lists.py Tree/list views (heirs, preview, will-executors)
|
||||||
window.py BalWindow controller (per-wallet GUI state)
|
window.py BalWindow controller (per-wallet GUI state)
|
||||||
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
||||||
|
cli/ Headless command-line layer (no Qt)
|
||||||
|
commands.py The @plugin_command transport layer (registers
|
||||||
|
the ``bal_*`` commands)
|
||||||
|
controller.py Headless replica of the Qt flows (later phases)
|
||||||
|
plugin.py Plugin(BalPlugin) entry point for the daemon
|
||||||
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
||||||
|
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
|
||||||
|
|
||||||
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
||||||
from ``gui.qt.plugin``.
|
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
|
||||||
|
(the shim), which imports ``Plugin`` from ``cli.plugin``.
|
||||||
|
|
||||||
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
||||||
``json_db.register_dict`` and replaced it with the path-based
|
``json_db.register_dict`` and replaced it with the path-based
|
||||||
@@ -40,3 +47,85 @@ available and adapts, so both releases keep working.
|
|||||||
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
||||||
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
||||||
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI command registration
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
|
||||||
|
# only imports the plugin package ``__init__`` to discover its commands.
|
||||||
|
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
|
||||||
|
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
|
||||||
|
# commands become available on the command line and over JSON-RPC without any Qt.
|
||||||
|
#
|
||||||
|
# The import must be zip-safe: when the plugin is loaded as an external zip,
|
||||||
|
# Electrum registers the package under the synthetic name
|
||||||
|
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
|
||||||
|
# ``bal`` (the zip-internal directory name), which is not present in
|
||||||
|
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
|
||||||
|
# resolve the real package name and import through ``importlib`` (the same
|
||||||
|
# trick as ``qt.py``).
|
||||||
|
import importlib
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_package_name() -> str:
|
||||||
|
"""Return the name this package is registered under in ``sys.modules``.
|
||||||
|
|
||||||
|
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
|
||||||
|
so ``__package__`` is already correct). External zip plugins are imported
|
||||||
|
under the synthetic name ``electrum_external_plugins.bal`` with
|
||||||
|
``__package__`` set to just the zip-internal directory name (``bal``); only
|
||||||
|
the synthetic name is present in ``sys.modules``.
|
||||||
|
"""
|
||||||
|
pkg = __package__ or "bal"
|
||||||
|
if pkg in _sys.modules:
|
||||||
|
return pkg
|
||||||
|
synthetic = "electrum_external_plugins." + __name__
|
||||||
|
if synthetic in _sys.modules:
|
||||||
|
return synthetic
|
||||||
|
return pkg
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||||
|
"""Backfill missing ancestor packages in ``sys.modules``.
|
||||||
|
|
||||||
|
When loaded from a zip as an external plugin, Electrum only executes the
|
||||||
|
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
|
||||||
|
may be missing, which would break sub-module imports. We stub it out as a
|
||||||
|
namespace package so ``importlib`` can still resolve its children (same
|
||||||
|
helper as ``qt.py``).
|
||||||
|
"""
|
||||||
|
parts = pkg_name.split(".")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
ancestor = ".".join(parts[:i])
|
||||||
|
if ancestor in _sys.modules:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
importlib.import_module(ancestor)
|
||||||
|
except Exception:
|
||||||
|
import types
|
||||||
|
|
||||||
|
module = types.ModuleType(ancestor)
|
||||||
|
module.__path__ = [] # mark as a (namespace) package
|
||||||
|
_sys.modules[ancestor] = module
|
||||||
|
|
||||||
|
|
||||||
|
def _register_cli_commands() -> None:
|
||||||
|
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
|
||||||
|
|
||||||
|
Guarded so a dual install (internal package AND external zip) cannot
|
||||||
|
register the same command names twice, which would make
|
||||||
|
``electrum.commands.plugin_command`` raise
|
||||||
|
"Command name bal_... already exists".
|
||||||
|
"""
|
||||||
|
from electrum import commands as _electrum_commands
|
||||||
|
|
||||||
|
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
|
||||||
|
return
|
||||||
|
pkg = _resolve_package_name()
|
||||||
|
_ensure_parent_packages(pkg)
|
||||||
|
importlib.import_module(pkg + ".cli.commands")
|
||||||
|
_electrum_commands._bal_cli_commands_registered = True
|
||||||
|
|
||||||
|
|
||||||
|
_register_cli_commands()
|
||||||
|
|||||||
19
bal/cli/__init__.py
Normal file
19
bal/cli/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
bal.cli
|
||||||
|
=======
|
||||||
|
|
||||||
|
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||||
|
|
||||||
|
This sub-package implements the ``"cmdline"`` front-end: it exposes the
|
||||||
|
plugin's functionality through Electrum ``bal_*`` commands while reusing only
|
||||||
|
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
|
||||||
|
PyQt or ``electrum.gui``.
|
||||||
|
|
||||||
|
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
|
||||||
|
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
|
||||||
|
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
|
||||||
|
|
||||||
|
Electrum discovers the plugin through ``manifest.json`` (``available_for``
|
||||||
|
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
|
||||||
|
zip-safe shim following the same pattern as ``qt.py``.
|
||||||
|
"""
|
||||||
424
bal/cli/commands.py
Normal file
424
bal/cli/commands.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
bal.cli.commands
|
||||||
|
================
|
||||||
|
|
||||||
|
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
|
||||||
|
|
||||||
|
This module is the *transport layer* of the command-line front-end: every
|
||||||
|
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
|
||||||
|
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
|
||||||
|
validate their arguments and delegate the real work to
|
||||||
|
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
|
||||||
|
never imports Qt.
|
||||||
|
|
||||||
|
It must stay lightweight: Electrum imports it during the CLI pre-parse
|
||||||
|
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
|
||||||
|
GUI/daemon startup, before any wallet or network object exists. The heavy
|
||||||
|
imports (``bal.core``, the controller) happen lazily inside each command.
|
||||||
|
|
||||||
|
Flags (see ``electrum.commands.plugin_command``):
|
||||||
|
|
||||||
|
* ``n`` -> requires a running daemon/network (always set for plugins);
|
||||||
|
* ``w`` -> resolves and injects the wallet from the daemon;
|
||||||
|
* ``p`` -> requires the wallet password (for signing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from electrum.commands import plugin_command
|
||||||
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
|
from .controller import BalController, _user_facing
|
||||||
|
|
||||||
|
plugin_name = "bal"
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(plugin, wallet):
|
||||||
|
"""Build the headless controller, or fail with a clear message."""
|
||||||
|
if plugin is None:
|
||||||
|
raise UserFacingException("the bal plugin is not enabled in this daemon")
|
||||||
|
if wallet is None:
|
||||||
|
raise UserFacingException("wallet not loaded")
|
||||||
|
return BalController(plugin, wallet)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(plugin, wallet, method, *args, **kwargs):
|
||||||
|
controller = _controller(plugin, wallet)
|
||||||
|
try:
|
||||||
|
return getattr(controller, method)(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
raise _user_facing(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Settings
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_list(self, plugin=None):
|
||||||
|
"""List all BAL plugin configuration options (key, name and value).
|
||||||
|
|
||||||
|
Returns a JSON object mapping every BAL configuration option (``bal_*``)
|
||||||
|
to an object with ``value``, ``default`` and ``name``.
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_list")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_get(self, key, plugin=None):
|
||||||
|
"""Show the current value of one BAL configuration option.
|
||||||
|
|
||||||
|
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_get", key)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_set(self, key, value, plugin=None):
|
||||||
|
"""Set a BAL configuration option (booleans, integers, strings, JSON).
|
||||||
|
|
||||||
|
arg:str:key:The configuration key (e.g. ``bal_user_type``).
|
||||||
|
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_set", key, value)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_reset(self, key, plugin=None):
|
||||||
|
"""Reset a BAL configuration option to its default value.
|
||||||
|
|
||||||
|
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_reset", key)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Heirs
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_list(self, wallet=None, plugin=None):
|
||||||
|
"""List the heirs of the current wallet.
|
||||||
|
|
||||||
|
Returns a JSON object mapping heir names to their ``[address, amount,
|
||||||
|
locktime]`` values.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_list")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_show(self, name, wallet=None, plugin=None):
|
||||||
|
"""Show the details of a single heir.
|
||||||
|
|
||||||
|
arg:str:name:The heir name.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_show", name)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
|
||||||
|
"""Add (or replace) an heir in the current wallet.
|
||||||
|
|
||||||
|
arg:str:name:The heir name.
|
||||||
|
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
|
||||||
|
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
|
||||||
|
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_update(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
address=None,
|
||||||
|
amount=None,
|
||||||
|
locktime=None,
|
||||||
|
wallet=None,
|
||||||
|
plugin=None,
|
||||||
|
):
|
||||||
|
"""Update an existing heir (only the given fields).
|
||||||
|
|
||||||
|
arg:str:name:The heir name.
|
||||||
|
arg:str:address:The new destination address.
|
||||||
|
arg:str:amount:The new amount in satoshis or a percentage.
|
||||||
|
arg:str:locktime:The new delivery locktime.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_delete(self, names, wallet=None, plugin=None):
|
||||||
|
"""Delete one or more heirs.
|
||||||
|
|
||||||
|
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_delete", names)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_import(self, path, wallet=None, plugin=None):
|
||||||
|
"""Import heirs from a JSON file (validated, merged).
|
||||||
|
|
||||||
|
arg:str:path:Path to the JSON file.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_import", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_export(self, path, wallet=None, plugin=None):
|
||||||
|
"""Export the heirs to a JSON file.
|
||||||
|
|
||||||
|
arg:str:path:Destination file path.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_export", path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Will-Executors
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_list(self, wallet=None, plugin=None):
|
||||||
|
"""List the will-executors for the current network.
|
||||||
|
|
||||||
|
Returns a JSON object mapping executor URLs to their records (address,
|
||||||
|
base_fee, status, info, selected, ...).
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_list")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_show(self, url, wallet=None, plugin=None):
|
||||||
|
"""Show the details of a single will-executor.
|
||||||
|
|
||||||
|
arg:str:url:The will-executor URL.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_show", url)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_add(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
address="",
|
||||||
|
base_fee=0,
|
||||||
|
info=None,
|
||||||
|
wallet=None,
|
||||||
|
plugin=None,
|
||||||
|
):
|
||||||
|
"""Add a new will-executor (not selected by default).
|
||||||
|
|
||||||
|
arg:str:url:The will-executor base URL.
|
||||||
|
arg:str:address:The executor fee address for this network.
|
||||||
|
arg:int:base_fee:The executor base fee in satoshis.
|
||||||
|
arg:str:info:A human-readable description.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_update(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
address=None,
|
||||||
|
base_fee=None,
|
||||||
|
info=None,
|
||||||
|
promo_code=None,
|
||||||
|
rename_to=None,
|
||||||
|
wallet=None,
|
||||||
|
plugin=None,
|
||||||
|
):
|
||||||
|
"""Update an existing will-executor (only the given fields).
|
||||||
|
|
||||||
|
arg:str:url:The will-executor URL to update.
|
||||||
|
arg:str:address:The new fee address.
|
||||||
|
arg:int:base_fee:The new base fee in satoshis.
|
||||||
|
arg:str:info:The new description.
|
||||||
|
arg:str:promo_code:The new promo code.
|
||||||
|
arg:str:rename_to:Optionally move the record to a new URL.
|
||||||
|
"""
|
||||||
|
return _call(
|
||||||
|
plugin,
|
||||||
|
wallet,
|
||||||
|
"willexecutors_update",
|
||||||
|
url,
|
||||||
|
address,
|
||||||
|
base_fee,
|
||||||
|
info,
|
||||||
|
promo_code,
|
||||||
|
rename_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_select(
|
||||||
|
self, url, value=True, wallet=None, plugin=None
|
||||||
|
):
|
||||||
|
"""Select (or deselect) a will-executor.
|
||||||
|
|
||||||
|
arg:str:url:The will-executor URL.
|
||||||
|
arg:bool:value:True to select, False to deselect.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
|
||||||
|
"""Delete one or more will-executors.
|
||||||
|
|
||||||
|
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_delete", urls)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
|
||||||
|
"""Ping the selected (or the given) will-executor servers.
|
||||||
|
|
||||||
|
Updates status/base_fee/address from each server and saves. Returns
|
||||||
|
``{url: {status, ok}}``.
|
||||||
|
|
||||||
|
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_ping", urls)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_download(self, wallet=None, plugin=None):
|
||||||
|
"""Download the will-executor list from the welist server and merge it.
|
||||||
|
|
||||||
|
Returns the number of records downloaded and the new total.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_download")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||||
|
"""Import will-executors from a JSON file (``{url: record}``).
|
||||||
|
|
||||||
|
arg:str:path:Path to the JSON file.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_import", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
||||||
|
"""Export the will-executors to a JSON file.
|
||||||
|
|
||||||
|
arg:str:path:Destination file path.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_export", path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Will
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_status(self, wallet=None, plugin=None):
|
||||||
|
"""Show the current will: per-transaction status, locktime and executors.
|
||||||
|
|
||||||
|
Returns a JSON object with a per-txid detail list and global status counts.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "will_status")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_check(self, wallet=None, plugin=None):
|
||||||
|
"""Check the local coherence of the will (heirs, executors, fees, locktime).
|
||||||
|
|
||||||
|
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "will_check")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_prepare(self, wallet=None, plugin=None):
|
||||||
|
"""Run the full prepare/inheritance flow (check, rebuild, persist).
|
||||||
|
|
||||||
|
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
|
||||||
|
``expired``, ``postponed``) and, when needed, the invalidation
|
||||||
|
transaction to sign and broadcast.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "prepare_will")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_autorebuild(self, wallet=None, plugin=None):
|
||||||
|
"""Run the automatic rebuild flow in one shot (check, rebuild, sign, push).
|
||||||
|
|
||||||
|
The same flow the GUI runs automatically on new wallet transactions:
|
||||||
|
the delivery date is anticipated by one day to orphan the old will on-chain
|
||||||
|
and, only when the anticipated locktime crosses the Check Alive threshold
|
||||||
|
(or the threshold is already in the past), an invalidation transaction is
|
||||||
|
returned instead. Signing needs a passwordless wallet.
|
||||||
|
|
||||||
|
Returns a JSON object with ``result``: ``valid``, ``no_heirs``,
|
||||||
|
``invalidated`` (with ``invalidation_tx``), ``nothing``,
|
||||||
|
``needs_signing`` or ``rebuilt``.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "auto_rebuild")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nwp", plugin_name)
|
||||||
|
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
|
||||||
|
"""Sign the valid, not-yet-complete will transactions (or just one).
|
||||||
|
|
||||||
|
Updates the COMPLETE status and the signature counters and persists.
|
||||||
|
|
||||||
|
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
|
||||||
|
"""
|
||||||
|
txids = [txid] if txid is not None else None
|
||||||
|
txs = _call(plugin, wallet, "sign_transactions", password, txids)
|
||||||
|
return {wid: str(tx) for wid, tx in txs.items()}
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_broadcast(
|
||||||
|
self, txid=None, force=False, wallet=None, plugin=None
|
||||||
|
):
|
||||||
|
"""Send the signed will transactions to their will-executors (in parallel).
|
||||||
|
|
||||||
|
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
|
||||||
|
|
||||||
|
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
|
||||||
|
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
|
||||||
|
"""
|
||||||
|
txids = [txid] if txid is not None else None
|
||||||
|
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_export(self, path, wallet=None, plugin=None):
|
||||||
|
"""Export the whole will to a JSON file.
|
||||||
|
|
||||||
|
arg:str:path:Destination file path.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "export_will", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_import_merge(self, path, wallet=None, plugin=None):
|
||||||
|
"""Merge a will file into the current will (PSBTs and statuses are merged).
|
||||||
|
|
||||||
|
arg:str:path:Path to the will JSON file.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "merge_will_from_file", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_invalidate(self, wallet=None, plugin=None):
|
||||||
|
"""Build the on-chain invalidation transaction for the current will.
|
||||||
|
|
||||||
|
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
|
||||||
|
transaction still needs to be signed and broadcast.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "invalidate_will_command")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
|
||||||
|
"""Ask the will-executors whether they hold our pushed transactions.
|
||||||
|
|
||||||
|
Runs the searchtx check in parallel, applies the per-item status and
|
||||||
|
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
|
||||||
|
|
||||||
|
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
|
||||||
|
"""
|
||||||
|
txids = [txid] if txid is not None else None
|
||||||
|
return _call(plugin, wallet, "check_transactions", txids)
|
||||||
1246
bal/cli/controller.py
Normal file
1246
bal/cli/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
21
bal/cli/plugin.py
Normal file
21
bal/cli/plugin.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
bal.cli.plugin
|
||||||
|
==============
|
||||||
|
|
||||||
|
The headless (command-line) entry point of the plugin.
|
||||||
|
|
||||||
|
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
|
||||||
|
adding any Qt hooks or per-window state. Electrum instantiates this class when
|
||||||
|
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
|
||||||
|
``bal/cmdline.py``, which re-exports it), and it is the object injected as
|
||||||
|
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..core.plugin_base import BalPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BalPlugin):
|
||||||
|
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
|
||||||
|
|
||||||
|
def __init__(self, parent, config, name):
|
||||||
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
69
bal/cmdline.py
Normal file
69
bal/cmdline.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
bal.cmdline
|
||||||
|
===========
|
||||||
|
|
||||||
|
Compatibility shim for Electrum's plugin loader (command-line front-end).
|
||||||
|
|
||||||
|
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
|
||||||
|
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
|
||||||
|
The real implementation lives in the ``bal.cli`` sub-package, so this module
|
||||||
|
re-exports ``Plugin`` from ``bal.cli.plugin``.
|
||||||
|
|
||||||
|
Like ``qt.py``, this file is not a one-line relative import because the very
|
||||||
|
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
|
||||||
|
imports the package under the synthetic top-level name
|
||||||
|
``electrum_external_plugins.bal`` and never registers the intermediate parent
|
||||||
|
packages. See the module docstring of ``bal.qt`` for the full rationale. The
|
||||||
|
shim resolves the run-time package name, backfills the missing parents into
|
||||||
|
``sys.modules`` and imports the real implementation via
|
||||||
|
:func:`importlib.import_module`.
|
||||||
|
|
||||||
|
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
|
||||||
|
headless process).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||||
|
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
|
||||||
|
|
||||||
|
When loaded from a zip as an external plugin, Electrum only executes the
|
||||||
|
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
|
||||||
|
package (e.g. ``electrum_external_plugins``) and any intermediate packages
|
||||||
|
may be missing from ``sys.modules``, which breaks relative/absolute
|
||||||
|
sub-module imports. We backfill them here using this module's own loader
|
||||||
|
so that ``importlib`` can find sibling sub-packages.
|
||||||
|
"""
|
||||||
|
parts = pkg_name.split(".")
|
||||||
|
# Walk from the top-most ancestor down to (but not including) pkg_name.
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
ancestor = ".".join(parts[:i])
|
||||||
|
if ancestor in sys.modules:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
importlib.import_module(ancestor)
|
||||||
|
except Exception:
|
||||||
|
# The synthetic root (e.g. 'electrum_external_plugins') often has no
|
||||||
|
# real spec. Create a minimal namespace package stub so that the
|
||||||
|
# import machinery can still resolve its children.
|
||||||
|
import types
|
||||||
|
|
||||||
|
module = types.ModuleType(ancestor)
|
||||||
|
module.__path__ = [] # mark as a (namespace) package
|
||||||
|
sys.modules[ancestor] = module
|
||||||
|
|
||||||
|
|
||||||
|
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
|
||||||
|
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
|
||||||
|
# loaded us.
|
||||||
|
_PKG = __package__ or "bal"
|
||||||
|
|
||||||
|
_ensure_parent_packages(_PKG)
|
||||||
|
|
||||||
|
# Import the real implementation using the fully-qualified, run-time package
|
||||||
|
# name so it works regardless of the synthetic prefix Electrum assigned.
|
||||||
|
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
|
||||||
|
|
||||||
|
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)
|
||||||
111
bal/core/checkalive.py
Normal file
111
bal/core/checkalive.py
Normal 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
|
||||||
@@ -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,6 +171,14 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
if is_op_return_address(heir[HEIR_ADDRESS]):
|
||||||
|
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
||||||
|
op_return_script = create_op_return_script(data_hex)
|
||||||
|
outputs.append(
|
||||||
|
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
||||||
|
)
|
||||||
|
description += f"{name}\n"
|
||||||
|
else:
|
||||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
outputs.append(
|
outputs.append(
|
||||||
PartialTxOutput.from_address_and_value(
|
PartialTxOutput.from_address_and_value(
|
||||||
@@ -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",
|
||||||
try:
|
(willexecutor or {}).get("url", "(none)"),
|
||||||
if "w!ll3x3c" in e.heirname:
|
e,
|
||||||
Willexecutors.is_selected(
|
|
||||||
e.heirname[len("w!ll3x3c") :], False
|
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except Exception:
|
|
||||||
raise 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])
|
||||||
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
|
amount = "0"
|
||||||
|
else:
|
||||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
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}")
|
||||||
|
if persist:
|
||||||
data.pop(k)
|
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
193
bal/core/input_rules.py
Normal 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
|
||||||
@@ -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
228
bal/core/reminders.py
Normal 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"
|
||||||
229
bal/core/util.py
229
bal/core/util.py
@@ -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."""
|
||||||
|
|||||||
361
bal/core/will.py
361
bal/core/will.py
@@ -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,18 +429,24 @@ 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)
|
||||||
|
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()
|
utxos = wallet.get_utxos()
|
||||||
filtered_inputs = []
|
filtered_inputs = []
|
||||||
prevout_to_spend = []
|
prevout_to_spend = []
|
||||||
@@ -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,9 +583,17 @@ 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):
|
||||||
|
continue
|
||||||
height = Will.check_tx_height(w.tx, wallet)
|
height = Will.check_tx_height(w.tx, wallet)
|
||||||
if height < 0:
|
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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,6 +291,8 @@ 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
|
||||||
|
attempts = max_retries + 1
|
||||||
|
for attempt in range(attempts):
|
||||||
try:
|
try:
|
||||||
if method == "get":
|
if method == "get":
|
||||||
response = Network.send_http_on_proxy(
|
response = Network.send_http_on_proxy(
|
||||||
@@ -297,30 +314,21 @@ class Willexecutors:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"unexpected {method=!r}")
|
raise Exception(f"unexpected {method=!r}")
|
||||||
|
_logger.debug(f"--> {response}")
|
||||||
|
return response
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
if count_reply < max_retries:
|
if attempt < max_retries:
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
f"timeout({attempt}) error: "
|
||||||
|
f"retry in {retry_sleep} sec..."
|
||||||
)
|
)
|
||||||
if retry_sleep:
|
if retry_sleep:
|
||||||
time.sleep(retry_sleep)
|
time.sleep(retry_sleep)
|
||||||
return Willexecutors.send_request(
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
data,
|
|
||||||
timeout=timeout,
|
|
||||||
handle_response=handle_response,
|
|
||||||
count_reply=count_reply + 1,
|
|
||||||
max_retries=max_retries,
|
|
||||||
retry_sleep=retry_sleep,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
_logger.debug(f"Too many timeouts: {count_reply}")
|
_logger.debug(f"Too many timeouts: {attempt}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
else:
|
return None
|
||||||
_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):
|
||||||
|
address = w.get("address")
|
||||||
|
if not isinstance(address, str) or not bitcoin.is_address(
|
||||||
|
address, net=constants.net
|
||||||
|
):
|
||||||
|
_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["url"] = url
|
||||||
willexecutor["status"] = 200
|
willexecutor["status"] = 200
|
||||||
willexecutor["base_fee"] = w["base_fee"]
|
willexecutor["base_fee"] = base_fee
|
||||||
willexecutor["address"] = w["address"]
|
willexecutor["address"] = address
|
||||||
willexecutor["info"] = w["info"]
|
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}")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -104,11 +203,6 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.bal_window.set_heir(prior_name)
|
self.bal_window.set_heir(prior_name)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.bal_window.set_heir((edit_key,) + original)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -157,6 +251,16 @@ 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
|
||||||
|
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.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),
|
||||||
|
)
|
||||||
|
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(),
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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))
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
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([
|
|
||||||
"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
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
Documentation for the **BAL** open‑source 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 inheritance‑options page loads
|
(the styled manual works fully offline; the inheritance‑options 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).
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — re‑Broadcast
|
|||||||
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
|
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<footer>This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
<footer>This document reflects BAL plugin v0.7.0. Behaviour is derived directly from
|
||||||
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 will‑executor(s)** | after **Broadcast** to executors |
|
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
||||||
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
| `CHECKED` | The will‑executor **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`.*
|
||||||
|
|||||||
@@ -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, `RAW‑1d` and it is, say, 5 p.m., the plugin will not
|
If you set, for example, `RAW‑1d` 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:
|
|||||||
|
|
||||||
## Will‑Executor service list
|
## Will‑Executor service list
|
||||||
|
|
||||||
This window opens from the Electrum menu, **Tools → Will‑executor**, and shows
|
This window opens from the Electrum menu, **Tools → Will‑Executors**, and shows
|
||||||
the official list of will‑executor servers.
|
the official list of will‑executor servers.
|
||||||
|
|
||||||
If you want to make changes — such as adding an additional will‑executor server —
|
If you want to make changes — such as adding an additional will‑executor server —
|
||||||
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each will‑executor 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 will‑executor | Azure‑green | `#73F3C8` |
|
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||||
| 4 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
| 4 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||||
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
| 5 | **Checked** | TX actually present in the will‑executor | 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 will‑executor | 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 will‑executor | Red | `#E83845` |
|
||||||
| 9 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||||
|
| 10 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||||
|
| 11 | **Updated** | TX re‑issued keeping the same locktime and heirs | Light violet | `#B266B2` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -448,6 +475,27 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
|||||||
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
||||||
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
||||||
> ensure the inheritance is correctly executed.
|
> ensure the inheritance is correctly executed.
|
||||||
|
>
|
||||||
|
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
|
||||||
|
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
|
||||||
|
> flow when Electrum closes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-rebuild on new transactions
|
||||||
|
|
||||||
|
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
|
||||||
|
> (available in **Tools → Plugins → BAL**, default OFF).
|
||||||
|
|
||||||
|
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
|
||||||
|
the will when new transactions are detected in the wallet (e.g. incoming
|
||||||
|
payments). The delivery date is anticipated by one day so the new will orphans
|
||||||
|
the old one on-chain without requiring a manual invalidation. An on-chain
|
||||||
|
invalidation is only needed when the anticipated locktime crosses the **Check
|
||||||
|
Alive** threshold (ADVANCED mode only).
|
||||||
|
|
||||||
|
This is useful for wallets that receive funds regularly: the inheritance stays
|
||||||
|
up-to-date without manual intervention.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 will‑executor server or collaboration, send your request to:
|
About installing a will‑executor server or collaboration, send your request to:
|
||||||
**info@bitcoin-after.life**
|
**info@bitcoin-after.life**
|
||||||
|
|
||||||
|
|||||||
285
make-release.sh
Executable file
285
make-release.sh
Executable 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
18
pyproject.toml
Normal 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
30
svatantrya.asc
Normal 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-----
|
||||||
@@ -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")
|
||||||
|
|||||||
11069
tests/karen7
11069
tests/karen7
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
792
tests/samanta7
Normal file
File diff suppressed because one or more lines are too long
@@ -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 = (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
|
||||||
|
feature.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
|
||||||
|
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
|
||||||
|
to OFF (False), and can be enabled and read back;
|
||||||
|
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
|
||||||
|
for the matching wallet and only when the setting is enabled;
|
||||||
|
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
|
||||||
|
re-entrancy / cooldown guards;
|
||||||
|
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
|
||||||
|
- no-op when the will is still valid;
|
||||||
|
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
|
||||||
|
invalidation, the rebuilt tx is anticipated to mine before the old);
|
||||||
|
- on-chain invalidation when the check-alive threshold is already in the
|
||||||
|
past (CheckAliveError);
|
||||||
|
- on-chain invalidation when the will is already expired;
|
||||||
|
- on-chain invalidation when the anticipated locktime would fall before
|
||||||
|
the check-alive threshold (and no sign/push in that case).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from electrum import bitcoin, crypto # noqa: E402
|
||||||
|
from electrum.descriptor import parse_descriptor # noqa: E402
|
||||||
|
from electrum.transaction import ( # noqa: E402
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
TxOutpoint,
|
||||||
|
)
|
||||||
|
from electrum.util import bfh # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import bal.gui.qt.window as window_mod # noqa: E402
|
||||||
|
from bal.core.heirs import Heirs # noqa: E402
|
||||||
|
from bal.core.plugin_base import BalConfig, BalPlugin # noqa: E402
|
||||||
|
from bal.core.util import Util # noqa: E402
|
||||||
|
from bal.core.will import Will # noqa: E402
|
||||||
|
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||||
|
from bal.gui.qt.plugin import Plugin # noqa: E402
|
||||||
|
from bal.gui.qt.window import BalWindow # noqa: E402
|
||||||
|
|
||||||
|
CONFIG_KEY = "bal_auto_rebuild"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
PRIVKEY = bytes(range(32))
|
||||||
|
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||||
|
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||||
|
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||||
|
FUNDING_SATOSHIS = 500000
|
||||||
|
|
||||||
|
|
||||||
|
def make_funding_input(prevout_hex="11" * 32):
|
||||||
|
"""Return a fake wallet UTXO spendable by the will."""
|
||||||
|
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||||
|
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||||
|
ADDRESS, FUNDING_SATOSHIS
|
||||||
|
)
|
||||||
|
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
utxo._TxInput__scriptpubkey = SCRIPT
|
||||||
|
utxo._TxInput__address = ADDRESS
|
||||||
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def put(self, key, value):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self, utxos):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.adb = None
|
||||||
|
self.network = None
|
||||||
|
self._utxos = list(utxos)
|
||||||
|
self._dust = 546
|
||||||
|
self._change_addresses = [ADDRESS]
|
||||||
|
self.labels = {}
|
||||||
|
self.save_db_calls = 0
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
self.save_db_calls += 1
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_label(self, txid, label):
|
||||||
|
self.labels[txid] = label
|
||||||
|
|
||||||
|
def get_all_labels(self):
|
||||||
|
return dict(self.labels)
|
||||||
|
|
||||||
|
def get_label_for_txid(self, txid):
|
||||||
|
return self.labels.get(txid, "")
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return list(self._utxos)
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return self._change_addresses
|
||||||
|
|
||||||
|
def add_input_info(self, txin, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_output_info(self, txout, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
class _TxInfo:
|
||||||
|
def __init__(self):
|
||||||
|
class _MinedStatus:
|
||||||
|
def height(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.tx_mined_status = _MinedStatus()
|
||||||
|
|
||||||
|
return _TxInfo()
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||||
|
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||||
|
for txin in tx.inputs():
|
||||||
|
if txin.script_descriptor is None:
|
||||||
|
txin.script_descriptor = descriptor
|
||||||
|
if txin.value_sats() is None:
|
||||||
|
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
tx.sign({PUBKEY: PRIVKEY})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfig:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
|
||||||
|
|
||||||
|
def electrum_path(self):
|
||||||
|
return self._tmpdir
|
||||||
|
|
||||||
|
def user_dir(self):
|
||||||
|
return self._tmpdir
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def set_key(self, key, value, save=True):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow:
|
||||||
|
def __init__(self, wallet):
|
||||||
|
self.wallet = wallet
|
||||||
|
self.messages = []
|
||||||
|
self.warnings = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
def get_decimal_point(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def show_message(self, text):
|
||||||
|
self.messages.append(str(text))
|
||||||
|
|
||||||
|
def show_warning(self, text, parent=None, title=None):
|
||||||
|
self.warnings.append(str(text))
|
||||||
|
|
||||||
|
def show_error(self, text):
|
||||||
|
self.errors.append(str(text))
|
||||||
|
|
||||||
|
def show_critical(self, text):
|
||||||
|
self.errors.append(str(text))
|
||||||
|
|
||||||
|
def update_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(utxos=None):
|
||||||
|
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
|
||||||
|
utxos = [make_funding_input()] if utxos is None else utxos
|
||||||
|
config = FakeConfig()
|
||||||
|
wallet = FakeWallet(utxos)
|
||||||
|
window = FakeWindow(wallet)
|
||||||
|
|
||||||
|
plugin = BalPlugin(None, config, "bal")
|
||||||
|
plugin.get_window_title = lambda title: str(title)
|
||||||
|
plugin.get_decimal_point = window.get_decimal_point
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
plugin.AUTO_REBUILD.set(True)
|
||||||
|
|
||||||
|
ctl = BalWindow.__new__(BalWindow)
|
||||||
|
ctl.bal_plugin = plugin
|
||||||
|
ctl.window = window
|
||||||
|
ctl.wallet = wallet
|
||||||
|
ctl.will = {}
|
||||||
|
ctl.willitems = {}
|
||||||
|
ctl.willexecutors = {}
|
||||||
|
ctl.will_settings = plugin.WILL_SETTINGS.get()
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
ctl.heirs = Heirs(wallet)
|
||||||
|
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
|
||||||
|
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
|
||||||
|
ctl.no_willexecutor = True
|
||||||
|
ctl.disable_plugin = False
|
||||||
|
ctl.ok = True
|
||||||
|
ctl.update_all = lambda: None
|
||||||
|
ctl._schedule_history_refresh = lambda: None
|
||||||
|
ctl._auto_rebuild_running = False
|
||||||
|
ctl._auto_rebuild_cooldown_until = 0.0
|
||||||
|
return ctl
|
||||||
|
|
||||||
|
|
||||||
|
def _no_willexecutors():
|
||||||
|
"""Force an empty will-executor list (offline tests)."""
|
||||||
|
return mock.patch.object(
|
||||||
|
Willexecutors,
|
||||||
|
"get_willexecutors",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single(controller):
|
||||||
|
"""Return (txid, WillItem) for the controller's single will item."""
|
||||||
|
assert len(controller.willitems) == 1, controller.willitems
|
||||||
|
return next(iter(controller.willitems.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_spending(controller, *prevout_hexes):
|
||||||
|
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||||
|
wanted = sorted(h for h in prevout_hexes)
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in controller.willitems.values()
|
||||||
|
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||||
|
]
|
||||||
|
assert len(items) == 1, controller.willitems
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Config key
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_auto_rebuild_config_defaults_off():
|
||||||
|
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||||
|
assert rebuild.get() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_config_can_be_enabled():
|
||||||
|
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||||
|
rebuild.set(True)
|
||||||
|
assert rebuild.get() is True
|
||||||
|
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Event wiring (Plugin._wallet_activity)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_wallet_activity_schedules_only_matching_wallet():
|
||||||
|
plugin = Plugin.__new__(Plugin)
|
||||||
|
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
|
||||||
|
wallet_a = FakeWallet([make_funding_input()])
|
||||||
|
wallet_b = FakeWallet([make_funding_input()])
|
||||||
|
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
class _Win:
|
||||||
|
wallet = wallet_a
|
||||||
|
ok = True
|
||||||
|
disable_plugin = False
|
||||||
|
|
||||||
|
def schedule_auto_rebuild(self):
|
||||||
|
scheduled.append(self)
|
||||||
|
|
||||||
|
win = _Win()
|
||||||
|
plugin.bal_windows = {"a": win}
|
||||||
|
|
||||||
|
plugin._wallet_activity(wallet_b)
|
||||||
|
assert scheduled == [], "a different wallet must not schedule a rebuild"
|
||||||
|
|
||||||
|
plugin._wallet_activity(wallet_a)
|
||||||
|
assert scheduled == [win], "the matching wallet must schedule a rebuild"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wallet_activity_skips_when_disabled():
|
||||||
|
plugin = Plugin.__new__(Plugin)
|
||||||
|
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
|
||||||
|
wallet_obj = FakeWallet([make_funding_input()])
|
||||||
|
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
class _Win:
|
||||||
|
wallet = wallet_obj
|
||||||
|
ok = True
|
||||||
|
disable_plugin = False
|
||||||
|
|
||||||
|
def schedule_auto_rebuild(self):
|
||||||
|
scheduled.append(self)
|
||||||
|
|
||||||
|
plugin.bal_windows = {"a": _Win()}
|
||||||
|
|
||||||
|
plugin._wallet_activity(wallet_obj)
|
||||||
|
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Scheduling / guards
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_schedule_auto_rebuild_debounces():
|
||||||
|
ctl = make_controller()
|
||||||
|
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
|
||||||
|
ctl.schedule_auto_rebuild()
|
||||||
|
single_shot.assert_called_once_with(
|
||||||
|
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_guards():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
assert ctl._auto_rebuild_allowed() is True
|
||||||
|
# Re-entrancy guard.
|
||||||
|
ctl._auto_rebuild_running = True
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
ctl._auto_rebuild_running = False
|
||||||
|
# Cooldown guard.
|
||||||
|
ctl._auto_rebuild_cooldown_until = time.time() + 100
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
ctl._auto_rebuild_cooldown_until = 0.0
|
||||||
|
assert ctl._auto_rebuild_allowed() is True
|
||||||
|
# Disabled / inactive guards.
|
||||||
|
ctl.disable_plugin = True
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
ctl.disable_plugin = False
|
||||||
|
ctl.ok = False
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_auto_rebuild_spawns_worker_when_allowed():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
|
||||||
|
started = []
|
||||||
|
|
||||||
|
class FakeThread:
|
||||||
|
def __init__(self, target, daemon=None):
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
started.append(self.target)
|
||||||
|
|
||||||
|
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
|
||||||
|
ctl._run_auto_rebuild()
|
||||||
|
assert len(started) == 1, "the worker thread must be spawned"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# maybe_auto_rebuild behaviour
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_when_disabled():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
ctl.bal_plugin.AUTO_REBUILD.set(False)
|
||||||
|
txid_before, _ = _single(ctl)
|
||||||
|
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
txid_after, _ = _single(ctl)
|
||||||
|
assert txid_after == txid_before, "disabled flow must not touch the will"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_without_will():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
assert not ctl.willitems
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_when_will_valid():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid_before, _ = _single(ctl)
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "_auto_sign_save_push"
|
||||||
|
) as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
txid_after, _ = _single(ctl)
|
||||||
|
assert txid_after == txid_before, "a valid will must not be rebuilt"
|
||||||
|
inv.assert_not_called()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
# A relative delivery recipe keeps the will coherent after the rebuild
|
||||||
|
# anticipates the locktime by one day (an absolute recipe would read the
|
||||||
|
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
|
||||||
|
ctl.will_settings["locktime"] = "1y"
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, old_item = _single(ctl)
|
||||||
|
old_locktime = int(old_item.tx.locktime)
|
||||||
|
|
||||||
|
# An incoming payment adds a second UTXO -> the will no longer covers
|
||||||
|
# the whole wallet (NotCompleteWillException).
|
||||||
|
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "push_transactions_to_willexecutors"
|
||||||
|
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True, "a stale will must be rebuilt"
|
||||||
|
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
|
||||||
|
push.assert_called_once()
|
||||||
|
history.assert_called_once()
|
||||||
|
|
||||||
|
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
|
||||||
|
# single-input transaction alongside it in the will).
|
||||||
|
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
# The new locktime must be at most the old one, so the new tx can be mined
|
||||||
|
# before the previous will.
|
||||||
|
assert int(new_item.tx.locktime) <= old_locktime
|
||||||
|
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||||
|
assert new_item.get_status("VALID")
|
||||||
|
|
||||||
|
# The rebuilt will is still valid now: no further work.
|
||||||
|
assert ctl.check_will() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_threshold_passed():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
# ADVANCED mode with a check-alive threshold already in the past.
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
ctl.will_settings["threshold"] = int(time.time()) - 3600
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "_auto_sign_save_push"
|
||||||
|
) as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
inv.assert_called_once()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
# Move the frozen delivery date into the past: "too late to
|
||||||
|
# anticipate" -> the old will must be invalidated on-chain.
|
||||||
|
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "_auto_sign_save_push"
|
||||||
|
) as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
inv.assert_called_once()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
now = time.time()
|
||||||
|
delivery = int(now + 3 * 86400)
|
||||||
|
ctl.will_settings["locktime"] = delivery
|
||||||
|
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
|
||||||
|
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
ctl.will_settings["threshold"] = delivery - 12 * 3600
|
||||||
|
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, _ = _single(ctl)
|
||||||
|
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
# The rebuild itself anticipates the delivery date by one day ONLY when
|
||||||
|
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
|
||||||
|
# same coins + same heirs). Real amounts are re-computed against the
|
||||||
|
# wallet balance, so a new UTXO normally changes them and the rebuilt
|
||||||
|
# will keeps the old locktime. Force the anticipating branch here to
|
||||||
|
# exercise the "anticipated locktime crosses the threshold" handling.
|
||||||
|
with mock.patch.object(
|
||||||
|
Will, "check_anticipate", return_value=delivery - 86400
|
||||||
|
):
|
||||||
|
with mock.patch.object(
|
||||||
|
ctl, "_auto_invalidate_will"
|
||||||
|
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
inv.assert_called_once(), (
|
||||||
|
"an anticipated locktime below the threshold must invalidate on-chain"
|
||||||
|
)
|
||||||
|
sign.assert_not_called(), (
|
||||||
|
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||||
|
"(the wizard stops and waits for the invalidation to confirm)"
|
||||||
|
)
|
||||||
|
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||||
|
"the rebuilt locktime must be anticipated by one day"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
|
for fn in tests:
|
||||||
|
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||||
|
fn()
|
||||||
|
print("OK")
|
||||||
|
print(f"\n{len(tests)} tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_run_all()
|
||||||
339
tests/test_cli_autorebuild.py
Normal file
339
tests/test_cli_autorebuild.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the headless auto-rebuild flow (``bal_will_autorebuild``).
|
||||||
|
|
||||||
|
The CLI equivalent of the GUI AUTO_REBUILD feature:
|
||||||
|
``BalController.auto_rebuild`` runs the wizard's close-time flow in a single
|
||||||
|
call. Everything is exercised offline against a fake signing wallet (the same
|
||||||
|
fixtures the GUI tests use), so no wallet, network or Qt is needed.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
|
||||||
|
* no-op when the will is still valid (``valid``);
|
||||||
|
* rebuild + sign + push when a new UTXO invalidates the will (``rebuilt``,
|
||||||
|
no on-chain invalidation: the rebuilt tx is anticipated to mine before
|
||||||
|
the old one);
|
||||||
|
* ``needs_signing`` when the wallet is encrypted;
|
||||||
|
* on-chain invalidation when the will is already expired (``expired``);
|
||||||
|
* on-chain invalidation when the anticipated locktime crosses the check-alive
|
||||||
|
threshold (``anticipation_crossed``) - and no sign/push in that case.
|
||||||
|
|
||||||
|
The ``no_heirs`` and ``threshold_passed`` paths live in
|
||||||
|
``test_cli_controller_offline.py``.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 tests/test_cli_autorebuild.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum import bitcoin, crypto
|
||||||
|
from electrum.descriptor import parse_descriptor
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
from electrum.transaction import PartialTxInput, PartialTxOutput, TxOutpoint
|
||||||
|
from electrum.util import bfh
|
||||||
|
|
||||||
|
from bal.cli.controller import BalController
|
||||||
|
from bal.core.will import Will
|
||||||
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
PRIVKEY = bytes(range(32))
|
||||||
|
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||||
|
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||||
|
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||||
|
FUNDING_SATOSHIS = 500000
|
||||||
|
|
||||||
|
|
||||||
|
def make_funding_input(prevout_hex="11" * 32):
|
||||||
|
"""Return a fake wallet UTXO spendable by the will."""
|
||||||
|
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||||
|
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||||
|
ADDRESS, FUNDING_SATOSHIS
|
||||||
|
)
|
||||||
|
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
utxo._TxInput__scriptpubkey = SCRIPT
|
||||||
|
utxo._TxInput__address = ADDRESS
|
||||||
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def put(self, key, value):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def get_dict(self, key):
|
||||||
|
return self._data.setdefault(key, {})
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_transaction(self, tx, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self, utxos, encrypted=False):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.adb = None
|
||||||
|
self.network = None
|
||||||
|
self._utxos = list(utxos)
|
||||||
|
self._dust = 546
|
||||||
|
self._change_addresses = [ADDRESS]
|
||||||
|
self._encrypted = encrypted
|
||||||
|
self.labels = {}
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return self._encrypted
|
||||||
|
|
||||||
|
def set_label(self, txid, label):
|
||||||
|
self.labels[txid] = label
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return list(self._utxos)
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return self._change_addresses
|
||||||
|
|
||||||
|
def add_input_info(self, txin, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_output_info(self, txout, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
class _TxInfo:
|
||||||
|
def __init__(self):
|
||||||
|
class _MinedStatus:
|
||||||
|
def height(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.tx_mined_status = _MinedStatus()
|
||||||
|
|
||||||
|
return _TxInfo()
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||||
|
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||||
|
for txin in tx.inputs():
|
||||||
|
if txin.script_descriptor is None:
|
||||||
|
txin.script_descriptor = descriptor
|
||||||
|
if txin.value_sats() is None:
|
||||||
|
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
tx.sign({PUBKEY: PRIVKEY})
|
||||||
|
|
||||||
|
|
||||||
|
class _Plugin:
|
||||||
|
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_autorebuild_")
|
||||||
|
from bal.cli.plugin import Plugin as RealPlugin
|
||||||
|
|
||||||
|
self.config = SimpleConfig(
|
||||||
|
{"electrum_path": self.tmpdir},
|
||||||
|
read_user_config_function=lambda path: {},
|
||||||
|
)
|
||||||
|
self.plugin = RealPlugin(None, self.config, "bal")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.plugin
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_willexecutors():
|
||||||
|
"""Force an empty will-executor list (offline tests)."""
|
||||||
|
return mock.patch.object(
|
||||||
|
Willexecutors,
|
||||||
|
"get_willexecutors",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_controller(plugin, wallet):
|
||||||
|
c = BalController(plugin, wallet)
|
||||||
|
c.will_settings["locktime"] = "1y"
|
||||||
|
c.heirs_add("alice", ADDRESS, "100000")
|
||||||
|
c.heirs_add("bob", ADDRESS, "100%")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _single(controller):
|
||||||
|
"""Return (txid, WillItem) for the controller's single will item."""
|
||||||
|
assert len(controller.willitems) == 1, controller.willitems
|
||||||
|
return next(iter(controller.willitems.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_spending(controller, *prevout_hexes):
|
||||||
|
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||||
|
wanted = sorted(h for h in prevout_hexes)
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in controller.willitems.values()
|
||||||
|
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||||
|
]
|
||||||
|
assert len(items) == 1, controller.willitems
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# auto_rebuild behaviour
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_when_will_valid():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
txid_before, _ = _single(c)
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "valid", result
|
||||||
|
assert next(iter(c.willitems)) == txid_before, (
|
||||||
|
"a valid will must not be rebuilt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
old_txid, old_item = _single(c)
|
||||||
|
old_locktime = int(old_item.tx.locktime)
|
||||||
|
|
||||||
|
# An incoming payment adds a second UTXO -> the will no longer
|
||||||
|
# covers the whole wallet (NotCompleteWillException).
|
||||||
|
wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "rebuilt", result
|
||||||
|
assert result["push"] == {}
|
||||||
|
|
||||||
|
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
assert int(new_item.tx.locktime) <= old_locktime, (
|
||||||
|
"the rebuilt tx must be anticipatable before the old will"
|
||||||
|
)
|
||||||
|
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||||
|
assert new_item.get_status("VALID")
|
||||||
|
|
||||||
|
# The rebuilt will is still valid now: no further work.
|
||||||
|
assert c.check_will() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_encrypted_wallet_requires_manual_signing():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()], encrypted=True)
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "needs_signing", result
|
||||||
|
assert result["will"]["count"] == 2
|
||||||
|
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||||
|
"an encrypted wallet must never be signed without the password"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
_, item = _single(c)
|
||||||
|
# Move the frozen delivery date into the past: "too late to
|
||||||
|
# anticipate" -> the old will must be invalidated on-chain.
|
||||||
|
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "invalidated", result
|
||||||
|
assert result["reason"] == "expired"
|
||||||
|
assert result["invalidation_tx"]["txid"] is not None
|
||||||
|
assert result["invalidation_tx"]["tx"]
|
||||||
|
assert not any(w.get_status("COMPLETE") for w in c.willitems.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
now = time.time()
|
||||||
|
delivery = int(now + 3 * 86400)
|
||||||
|
# ADVANCED mode: the check-alive threshold sits 12h before delivery,
|
||||||
|
# so an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||||
|
plugin.USER_TYPE.set("advanced")
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.will_settings["locktime"] = delivery
|
||||||
|
c.will_settings["threshold"] = delivery - 12 * 3600
|
||||||
|
c.prepare_will()
|
||||||
|
old_txid, _ = _single(c)
|
||||||
|
wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
# Force the anticipating branch (see the GUI test for the rationale:
|
||||||
|
# with a new UTXO the real amounts change, so the natural rebuild
|
||||||
|
# keeps the old locktime).
|
||||||
|
with mock.patch.object(
|
||||||
|
Will, "check_anticipate", return_value=delivery - 86400
|
||||||
|
):
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "invalidated", result
|
||||||
|
assert result["reason"] == "anticipation_crossed"
|
||||||
|
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||||
|
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||||
|
"(the wizard stops and waits for the invalidation to confirm)"
|
||||||
|
)
|
||||||
|
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||||
|
"the rebuilt locktime must be anticipated by one day"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
|
for fn in tests:
|
||||||
|
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||||
|
fn()
|
||||||
|
print("OK")
|
||||||
|
print(f"\n{len(tests)} tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_run_all()
|
||||||
150
tests/test_cli_commands_registered.py
Normal file
150
tests/test_cli_commands_registered.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Test: BAL plugin CLI commands are registered with Electrum.
|
||||||
|
|
||||||
|
Verifies that importing the plugin through Electrum's own plugin loader
|
||||||
|
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
|
||||||
|
to pre-parse the command line) registers every ``bal_*`` command with
|
||||||
|
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
|
||||||
|
|
||||||
|
It also asserts the basic contract enforced by ``plugin_command``: each command
|
||||||
|
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
|
||||||
|
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
|
||||||
|
signing also ``'p'``).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 tests/test_cli_commands_registered.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from electrum import commands as electrum_commands
|
||||||
|
from electrum.plugin import Plugins
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
|
||||||
|
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
|
||||||
|
# added in later phases must be appended here so the registration test keeps
|
||||||
|
# proving the whole list is wired up.
|
||||||
|
EXPECTED_COMMANDS = {
|
||||||
|
# Settings (no wallet required)
|
||||||
|
"bal_settings_list": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
"bal_settings_get": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
"bal_settings_set": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
"bal_settings_reset": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
# Heirs
|
||||||
|
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
# Will-Executors
|
||||||
|
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
# Will
|
||||||
|
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_autorebuild": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
|
||||||
|
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _isolated_config(**overrides):
|
||||||
|
"""A throwaway SimpleConfig that never touches the real Electrum config.
|
||||||
|
|
||||||
|
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
|
||||||
|
the tests cannot pollute the user's config files. The bal plugin is
|
||||||
|
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
|
||||||
|
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
|
||||||
|
"""
|
||||||
|
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
|
||||||
|
opts.update(overrides)
|
||||||
|
cfg = SimpleConfig(opts)
|
||||||
|
cfg.enable_plugin("bal")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_commands_registered():
|
||||||
|
cfg = _isolated_config()
|
||||||
|
Plugins(cfg, cmd_only=True)
|
||||||
|
for name, flags in EXPECTED_COMMANDS.items():
|
||||||
|
assert name in electrum_commands.known_commands, f"{name} not registered"
|
||||||
|
cmd = electrum_commands.known_commands[name]
|
||||||
|
assert cmd.name == name
|
||||||
|
assert cmd.requires_network is flags["requires_network"]
|
||||||
|
assert cmd.requires_wallet is flags["requires_wallet"]
|
||||||
|
assert cmd.requires_password is flags["requires_password"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commands_are_coroutines():
|
||||||
|
cfg = _isolated_config()
|
||||||
|
Plugins(cfg, cmd_only=True)
|
||||||
|
for name in EXPECTED_COMMANDS:
|
||||||
|
func = getattr(electrum_commands.Commands, name, None)
|
||||||
|
assert func is not None, f"{name} missing from Commands"
|
||||||
|
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_duplicate_registration():
|
||||||
|
"""Loading the plugin twice must not raise "Command name bal_... already
|
||||||
|
exists" (the guard in bal/__init__._register_cli_commands)."""
|
||||||
|
cfg = _isolated_config()
|
||||||
|
plugins = Plugins(cfg, cmd_only=True)
|
||||||
|
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
|
||||||
|
for name in EXPECTED_COMMANDS:
|
||||||
|
assert name in electrum_commands.known_commands
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_docstrings_document_all_args():
|
||||||
|
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
|
||||||
|
CLI parser prints "undocumented argument ..." otherwise)."""
|
||||||
|
cfg = _isolated_config()
|
||||||
|
Plugins(cfg, cmd_only=True)
|
||||||
|
for name in EXPECTED_COMMANDS:
|
||||||
|
cmd = electrum_commands.known_commands[name]
|
||||||
|
for varname in list(cmd.params) + list(cmd.options):
|
||||||
|
if varname in ("wallet", "wallet_path", "plugin", "password"):
|
||||||
|
continue
|
||||||
|
assert varname in cmd.arg_descriptions, (
|
||||||
|
f"{name}: undocumented argument {varname}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All CLI registration tests passed")
|
||||||
252
tests/test_cli_controller_offline.py
Normal file
252
tests/test_cli_controller_offline.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Offline tests for the headless ``bal.cli.controller.BalController``.
|
||||||
|
|
||||||
|
These run without a wallet, a network or Qt: the controller is exercised
|
||||||
|
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
|
||||||
|
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
|
||||||
|
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
|
||||||
|
are covered here; build/sign/push flows need a live wallet and network and are
|
||||||
|
exercised by the group tests instead.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
python3 tests/test_cli_controller_offline.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
|
from bal.cli.controller import BalController
|
||||||
|
|
||||||
|
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def put(self, key, value):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def get_dict(self, key):
|
||||||
|
return self._data.setdefault(key, {})
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_transaction(self, tx, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.network = None
|
||||||
|
self.adb = None
|
||||||
|
self._dust = 500
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_label(self, txid, text):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return [VALID_ADDRESS]
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin:
|
||||||
|
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
|
||||||
|
from bal.cli.plugin import Plugin as RealPlugin
|
||||||
|
|
||||||
|
self.config = SimpleConfig(
|
||||||
|
{"electrum_path": self.tmpdir},
|
||||||
|
read_user_config_function=lambda path: {},
|
||||||
|
)
|
||||||
|
self.plugin = RealPlugin(None, self.config, "bal")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.plugin
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_controller(plugin):
|
||||||
|
return BalController(plugin, FakeWallet())
|
||||||
|
|
||||||
|
|
||||||
|
def test_controller_init_empty():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.willitems == {}
|
||||||
|
assert c.will == {}
|
||||||
|
assert c.heirs == {}
|
||||||
|
assert isinstance(c.will_settings, dict)
|
||||||
|
assert "baltx_fees" in c.will_settings
|
||||||
|
# Fresh config: no stored will-executors. On mainnet the default
|
||||||
|
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
|
||||||
|
# "bitcoin", so nothing is injected either.
|
||||||
|
assert c.willexecutors == {}
|
||||||
|
assert c.no_willexecutor is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_roundtrip():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
listing = c.settings_list()
|
||||||
|
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
|
||||||
|
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
|
||||||
|
assert c.settings_get(tx_key)["value"] == 100
|
||||||
|
|
||||||
|
c.settings_set("bal_tx_fees", "150")
|
||||||
|
assert c.settings_get("bal_tx_fees")["value"] == 150
|
||||||
|
assert c.settings_get("TX_FEES")["value"] == 150
|
||||||
|
|
||||||
|
c.settings_set("bal_no_willexecutor", "true")
|
||||||
|
assert c.settings_get("bal_no_willexecutor")["value"] is True
|
||||||
|
|
||||||
|
c.settings_reset("bal_tx_fees")
|
||||||
|
assert c.settings_get("bal_tx_fees")["value"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_unknown_key():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
try:
|
||||||
|
c.settings_get("bal_does_not_exist")
|
||||||
|
raise AssertionError("expected UserFacingException")
|
||||||
|
except UserFacingException as e:
|
||||||
|
assert "Unknown BAL setting" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heirs_crud():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||||
|
assert c.heirs["alice"][0] == VALID_ADDRESS
|
||||||
|
assert c.heirs["alice"][1] == "100000"
|
||||||
|
|
||||||
|
c.heirs_update("alice", amount="200000")
|
||||||
|
assert c.heirs["alice"][1] == "200000"
|
||||||
|
assert c.heirs_show("alice")["value"][1] == "200000"
|
||||||
|
|
||||||
|
assert "alice" in c.heirs_list()
|
||||||
|
c.heirs_delete(["alice"])
|
||||||
|
assert "alice" not in c.heirs_list()
|
||||||
|
|
||||||
|
|
||||||
|
def test_heirs_add_op_return():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
|
||||||
|
assert c.heirs["note"][1] == "0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_willexecutors_crud():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.willexecutors == {}
|
||||||
|
|
||||||
|
new_url = "https://executor.example.invalid"
|
||||||
|
c.willexecutors_add(new_url, address="", base_fee=250)
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
|
||||||
|
|
||||||
|
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
|
||||||
|
|
||||||
|
c.willexecutors_select([new_url], select=True)
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
|
||||||
|
|
||||||
|
renamed = "https://executor2.example.invalid"
|
||||||
|
c.willexecutors_update(new_url, rename_to=renamed)
|
||||||
|
assert renamed in c.willexecutors
|
||||||
|
assert new_url not in c.willexecutors
|
||||||
|
|
||||||
|
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
|
||||||
|
assert renamed not in c.willexecutors
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_status_empty():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
status = c.will_status()
|
||||||
|
assert status["count"] == 0
|
||||||
|
assert status["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_check_no_heirs_raises():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
try:
|
||||||
|
c.will_check()
|
||||||
|
raise AssertionError("expected UserFacingException")
|
||||||
|
except UserFacingException as e:
|
||||||
|
assert "heir" in str(e).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_no_heirs():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.auto_rebuild() == {"result": "no_heirs"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_threshold_passed_invalidates():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||||
|
plugin.USER_TYPE.set("advanced")
|
||||||
|
c.will_settings["threshold"] = int(time.time()) - 3600
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
assert result["result"] == "invalidated"
|
||||||
|
assert result["reason"] == "threshold_passed"
|
||||||
|
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# runner
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = 0
|
||||||
|
for name, fn in sorted(globals().items()):
|
||||||
|
if not name.startswith("test_") or not callable(fn):
|
||||||
|
continue
|
||||||
|
print(f" {name}")
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as e:
|
||||||
|
failures += 1
|
||||||
|
print(f" [FAIL] {name}: {e!r}")
|
||||||
|
if failures:
|
||||||
|
print(f"[FAIL] {failures} test(s) failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("[OK] All offline controller tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
178
tests/test_core_checkalive.py
Normal file
178
tests/test_core_checkalive.py
Normal 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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
165
tests/test_core_input_rules.py
Normal file
165
tests/test_core_input_rules.py
Normal 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")
|
||||||
@@ -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}
|
||||||
|
|||||||
299
tests/test_core_reminders.py
Normal file
299
tests/test_core_reminders.py
Normal 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")
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
486
tests/test_core_will_invalidate.py
Normal file
486
tests/test_core_will_invalidate.py
Normal 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")
|
||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
397
tests/test_group_e_karen7_invalidate.py
Normal file
397
tests/test_group_e_karen7_invalidate.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]))
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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.")
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
216
tests/test_gui_prepare_will_history.py
Normal file
216
tests/test_gui_prepare_will_history.py
Normal 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")
|
||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
404
tests/test_gui_will_flows.py
Normal file
404
tests/test_gui_will_flows.py
Normal 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
150
tests/test_gui_will_menu.py
Normal 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")
|
||||||
@@ -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)
|
||||||
|
|||||||
226
tests/test_heir_relative_anchor.py
Normal file
226
tests/test_heir_relative_anchor.py
Normal 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")
|
||||||
603
tests/test_import_will_details.py
Normal file
603
tests/test_import_will_details.py
Normal 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")
|
||||||
612
tests/test_no_willexecutor_karen7.py
Normal file
612
tests/test_no_willexecutor_karen7.py
Normal 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)
|
||||||
165
tests/test_rebuild_on_close_setting.py
Normal file
165
tests/test_rebuild_on_close_setting.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the "Rebuild will on wallet close" (REBUILD_ON_CLOSE) setting.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
|
||||||
|
* the persisted ``bal_rebuild_on_close`` configuration key exists and
|
||||||
|
defaults to ON (True), and can be turned off and read back;
|
||||||
|
* ``BalWindow.on_close()`` runs the "Build your will" wizard
|
||||||
|
(``BalBuildWillDialog.build_will_task()``) when the setting is ON;
|
||||||
|
* ``BalWindow.on_close()`` SKIPS the wizard when the setting is OFF, but
|
||||||
|
still calls ``save_willitems()`` so the last built state is persisted.
|
||||||
|
|
||||||
|
The on_close tests drive the real ``BalWindow.on_close`` method with a
|
||||||
|
light-weight fake controller and a recording stub for ``BalBuildWillDialog``,
|
||||||
|
so no full wallet/GUI machinery is needed.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_rebuild_on_close_setting.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import bal.gui.qt.window as window_mod # noqa: E402
|
||||||
|
from bal.core.plugin_base import BalConfig # noqa: E402
|
||||||
|
|
||||||
|
CONFIG_KEY = "bal_rebuild_on_close"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mocks
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class FakeConfig:
|
||||||
|
"""Minimal mock for Electrum's config object (key/value store)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._store = {}
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._store.get(key, default)
|
||||||
|
|
||||||
|
def set_key(self, key, value, save=True):
|
||||||
|
self._store[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBuildWillDialog:
|
||||||
|
"""Recording stub for BalBuildWillDialog, patched into window.py."""
|
||||||
|
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, bal_window):
|
||||||
|
self.bal_window = bal_window
|
||||||
|
FakeBuildWillDialog.instances.append(self)
|
||||||
|
|
||||||
|
def build_will_task(self):
|
||||||
|
self.bal_window._wizard_ran = True
|
||||||
|
|
||||||
|
|
||||||
|
class _Tabs:
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _NoOp:
|
||||||
|
willexecutors_action = None
|
||||||
|
tabs = _Tabs()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def toggle_tab(self, tab):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def removeAction(self, action):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_window(rebuild_on_close):
|
||||||
|
"""Return a fake controller with the attributes on_close() touches."""
|
||||||
|
fake = types.SimpleNamespace()
|
||||||
|
fake.disable_plugin = False
|
||||||
|
fake.bal_plugin = types.SimpleNamespace(
|
||||||
|
REBUILD_ON_CLOSE=BalConfig(FakeConfig(), CONFIG_KEY, rebuild_on_close)
|
||||||
|
)
|
||||||
|
fake.willitems = {}
|
||||||
|
fake.will = {}
|
||||||
|
fake.saved = []
|
||||||
|
fake.save_willitems = lambda: fake.saved.append("save")
|
||||||
|
fake.heirs_tab = _NoOp()
|
||||||
|
fake.will_tab = _NoOp()
|
||||||
|
fake.tools_menu = _NoOp()
|
||||||
|
fake.window = _NoOp()
|
||||||
|
fake._menubar_initialized = True
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _call_on_close(fake):
|
||||||
|
original = window_mod.BalBuildWillDialog
|
||||||
|
FakeBuildWillDialog.instances = []
|
||||||
|
try:
|
||||||
|
window_mod.BalBuildWillDialog = FakeBuildWillDialog
|
||||||
|
window_mod.BalWindow.on_close(fake)
|
||||||
|
finally:
|
||||||
|
window_mod.BalBuildWillDialog = original
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Config key
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_rebuild_on_close_config_defaults_on():
|
||||||
|
"""bal_rebuild_on_close must default to ON (True) when not yet stored."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
rebuild = BalConfig(cfg, CONFIG_KEY, True)
|
||||||
|
assert rebuild.get() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_on_close_config_can_be_disabled():
|
||||||
|
"""Once turned off and persisted, bal_rebuild_on_close reads back False."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
rebuild = BalConfig(cfg, CONFIG_KEY, True)
|
||||||
|
rebuild.set(False)
|
||||||
|
assert rebuild.get() is False
|
||||||
|
# A fresh wrapper over the same config still sees the stored value.
|
||||||
|
assert BalConfig(cfg, CONFIG_KEY, True).get() is False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# on_close() behaviour
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_on_close_runs_wizard_when_enabled():
|
||||||
|
"""With REBUILD_ON_CLOSE ON, on_close() builds the will and saves it."""
|
||||||
|
fake = _make_fake_window(True)
|
||||||
|
_call_on_close(fake)
|
||||||
|
assert len(FakeBuildWillDialog.instances) == 1, "wizard must be opened"
|
||||||
|
assert fake._wizard_ran is True, "wizard build_will_task must run"
|
||||||
|
assert fake.saved == ["save"], "save_willitems must run"
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_close_skips_wizard_when_disabled():
|
||||||
|
"""With REBUILD_ON_CLOSE OFF, on_close() skips the wizard but saves."""
|
||||||
|
fake = _make_fake_window(False)
|
||||||
|
_call_on_close(fake)
|
||||||
|
assert len(FakeBuildWillDialog.instances) == 0, "wizard must NOT be opened"
|
||||||
|
assert not hasattr(fake, "_wizard_ran"), "wizard must not run"
|
||||||
|
assert fake.saved == ["save"], "save_willitems must still run"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_rebuild_on_close_config_defaults_on()
|
||||||
|
test_rebuild_on_close_config_can_be_disabled()
|
||||||
|
test_on_close_runs_wizard_when_enabled()
|
||||||
|
test_on_close_skips_wizard_when_disabled()
|
||||||
|
print("OK: all tests passed")
|
||||||
@@ -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)
|
||||||
|
|||||||
136
tests/test_settings_history_dialog.py
Normal file
136
tests/test_settings_history_dialog.py
Normal 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")
|
||||||
191
tests/test_sync_locktime_built_txs.py
Normal file
191
tests/test_sync_locktime_built_txs.py
Normal 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.")
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user