diff --git a/AGENTS.md b/AGENTS.md index cff4580..fd85809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ The plugin's `bal/` directory is symlinked into ## Test & verify -Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs -its `test_*` functions from `if __name__ == "__main__"`. Run a file directly: +Tests work **both** as standalone scripts and via pytest (tests use `def test_*` +naming and also have `if __name__ == "__main__"` blocks). Run a single file +directly: ```bash source /home/steal/devel/bal/electrum/env/bin/activate @@ -29,6 +30,13 @@ python3 tests/test_core_heirs.py # core, no Qt needed QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen ``` +Or run a batch with pytest (as `make-release.sh` does): + +```bash +source /home/steal/devel/bal/electrum/env/bin/activate +QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q +``` + - Most core tests run offline (no wallet/network). Some files (`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`) exercise will-executor/network flows and need the live servers — don't rely on @@ -43,7 +51,8 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of - **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and `tests/`). Do not run `--fix` wholesale and do not try to silence everything; just avoid adding new violations. Config: `pyproject.toml` (line-length 88, - E501 ignored). + E501 ignored). Per-file ignores suppress `F403`/`F405` for the intentional + `from .common import *` hub pattern in `bal/gui/qt/`. - Lint via the repo venv: `/home/steal/devel/bal/bal-electrum-plugin/venv/bin/ruff` - Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json` (`extraPaths: ["../electrum"]`). Pyright reports many false positives on @@ -53,9 +62,18 @@ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need of ## Architecture - `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`, - `plugin_base.py`, `util.py`). Must never import Qt. + `plugin_base.py`, `util.py`, `checkalive.py`, `reminders.py`, + `input_rules.py`). + Must never import Qt. - `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller, - `plugin.py` is the Electrum `@hooks` entry, `qt.py` is a zipimport shim. + `plugin.py` is the Electrum `@hooks` entry. `qt.py` is a zipimport shim. + `common.py` uses `import *` intentionally (ruff suppresses F403/F405 here); + `bal/gui/qt/*.py` all import from it. +- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon + entry point, `commands.py` registers `bal_*` commands with Electrum. +- `bal/wallet_util/` = wallet helper utilities for Qt and core. +- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers + via `manifest.json`; they import the real `Plugin` class via `importlib`. - `bal/manifest.json` = version source of truth (Electrum reads it; also read by `make-release.sh`). - Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB diff --git a/CHANGELOG.md b/CHANGELOG.md index bf76c56..24623fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2568,3 +2568,343 @@ of a session, without requiring the normal wizard flow to have run first. `test_merge_will_validity_error_logs_without_crashing`. **Outcome:** DONE. + +--- + +## 49. OP_RETURN support for heirs + +**Date:** 2026-07-30 + +**Goal:** allow heirs to produce an OP_RETURN output instead of a regular BTC +payment. An address prefixed with `OP_RETURN:` carries hex data (max 80 bytes); +such heirs always have amount 0 and are excluded from the normal amount +calculations (percentage normalization, dust checks, leftover redistribution). + +**What changed:** + +- `bal/core/heirs.py` + - `validate_heir`: new `OP_RETURN:` 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 ` 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. diff --git a/HANDOFF.md b/HANDOFF.md index 6a17253..2784bf6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -58,6 +58,7 @@ bal/ <- the plugin package (this is what ships in the ZI __init__.py <- package docstring (no version here anymore) manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version) qt.py <- zipimport shim used when loaded as an external ZIP plugin + cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline') core/ plugin_base.py <- get_version() reads the version from manifest.json (zip-safe) heirs.py <- HEIRS + transaction building (prepare_lists, @@ -67,6 +68,14 @@ bal/ <- the plugin package (this is what ships in the ZI willexecutors.py <- remote will-executor services handling (is_selected / is_valid, parallel push/check). util.py <- locktime parsing/most helpers (timestamps only). + checkalive.py <- resolve_date_to_check, check_alive_expired (GUI-free). + reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free). + input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free). + cli/ <- headless command-line layer (no Qt) + __init__.py <- registers bal_* commands on import + commands.py <- bal_* daemon commands (@plugin_command, async, thin transport) + controller.py <- BalController: headless replica of BalWindow (no Qt) + plugin.py <- CLI Plugin entry point (extends BalPlugin, no Qt hooks) gui/qt/ common.py <- shared imports; every gui module does `from .common import *`. Add new shared imports HERE. @@ -80,7 +89,6 @@ bal/ <- the plugin package (this is what ships in the ZI wallet_util/ <- standalone wallet-inspection helpers, no Qt tests/ <- standalone test scripts (see Section 3). docs/ <- user manual + inheritance-options guide (.md sources). -bal_cli.py <- headless CLI (heirs/will build/sign/push/check), no Qt. build_zip.py <- builds the shippable ZIP (36 files). CHANGELOG.md <- numbered task log (English). .agent_memory_tasks.md <- terse internal memory notes per task batch. @@ -295,8 +303,8 @@ See Section 5 for details. update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry. - Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14** (docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 — - manifest.json version). All merged into `main`. -- Releases: latest is **v0.6.1**; v0.6.0 and v0.5.18 before it; the older + manifest.json version); all merged into `main`. +- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older v0.2.x line is kept in history. --- @@ -346,6 +354,11 @@ See Section 5 for details. - **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes allowed) and the `merge_will` missing-`date_to_check` crash fix (see CHANGELOG). +- **v0.7.0** — OP_RETURN heirs; core extraction (checkalive, reminders, + input_rules); RLock pickle fix; `REBUILD_ON_CLOSE`; headless CLI layer + (`bal/cli/`, `bal/cmdline.py`, 30 `bal_*` commands); `AUTO_REBUILD` on new + transactions; `bal_will_autorebuild` CLI command; removed redundant + "Add transaction without willexecutor" from settings dialog. ### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail) - **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an diff --git a/README.md b/README.md index aefe697..ca3f196 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the that transfer your funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers. -This repository contains a **behavior-preserving refactor** of the original -plugin. The logic was kept byte-identical wherever possible; only the file -layout was reorganized to cleanly separate **business logic** from the -**PyQt GUI**. +This repository contains a **refactored and extended** version of the original +plugin. The logic was reorganized to cleanly separate **business logic** from the +**PyQt GUI**, and new features have been added including a headless CLI, +auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar +reminders. ## Repository layout @@ -16,12 +17,20 @@ layout was reorganized to cleanly separate **business logic** from the bal/ the installable Electrum plugin package ├── manifest.json plugin metadata (Electrum reads this) ├── qt.py Qt entry-point shim (re-exports Plugin) +├── cmdline.py CLI entry-point shim (re-exports Plugin) ├── core/ GUI-free logic (importable without Qt) │ ├── util.py │ ├── plugin_base.py │ ├── heirs.py │ ├── will.py -│ └── willexecutors.py +│ ├── willexecutors.py +│ ├── checkalive.py +│ ├── reminders.py +│ └── input_rules.py +├── cli/ headless command-line layer (no Qt) +│ ├── commands.py bal_* daemon commands (@plugin_command) +│ ├── controller.py headless BalController (replicates BalWindow) +│ └── plugin.py CLI Plugin entry point ├── gui/qt/ PyQt6 presentation layer │ ├── theme.py status → color mapping │ ├── common.py shared imports / helpers @@ -30,6 +39,7 @@ bal/ the installable Electrum plugin package │ ├── dialogs.py dialog windows │ ├── lists.py tree/list views │ ├── window.py per-wallet GUI controller +│ ├── window_utils.py GUI utility helpers │ └── plugin.py Plugin (Electrum @hooks → GUI) ├── icons/ wallet_util/ LICENSE README.md build_zip.py builds a clean, zipimport-friendly distribution zip @@ -77,6 +87,42 @@ Copy the `bal/` directory into your Electrum installation's `electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json` exists, then enable it from **Tools → Plugins**. +## Command-line / headless usage + +BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer +exposes `bal_*` commands that replicate the full inheritance cycle. + +### Prerequisites + +- An **Electrum daemon** running (`electrum daemon -d`) +- A wallet loaded (`electrum load_wallet`) + +### Available commands + +| Category | Commands | +|----------|----------| +| Settings | `bal_settings_list`, `bal_settings_get`, `bal_settings_set`, `bal_settings_reset` | +| Heirs | `bal_heirs_list`, `bal_heirs_show`, `bal_heirs_add`, `bal_heirs_update`, `bal_heirs_delete`, `bal_heirs_import`, `bal_heirs_export` | +| Will-Executors | `bal_willexecutors_list`, `bal_willexecutors_show`, `bal_willexecutors_add`, `bal_willexecutors_update`, `bal_willexecutors_select`, `bal_willexecutors_delete`, `bal_willexecutors_ping`, `bal_willexecutors_download`, `bal_willexecutors_import`, `bal_willexecutors_export` | +| Will | `bal_will_status`, `bal_will_check`, `bal_will_prepare`, `bal_will_autorebuild`, `bal_will_sign`, `bal_will_broadcast`, `bal_will_export`, `bal_will_import_merge`, `bal_will_invalidate`, `bal_will_check_executor` | + +### Example workflow + +```bash +electrum daemon -d +electrum load_wallet +electrum bal_heirs_list +electrum bal_will_prepare +electrum bal_will_sign --password '...' +electrum bal_will_broadcast +electrum stop +``` + +All commands require a running daemon (Electrum's `plugin_command` enforces +this). Wallet-bound commands (`bal_heirs_*`, `bal_will_*`, etc.) require the +wallet to be loaded first. Signing commands require `--password` for encrypted +wallets. + ## Inheritance safety: anticipate / postpone A will transaction is signed with a **fixed, immutable locktime** and then diff --git a/bal/core/checkalive.py b/bal/core/checkalive.py index afffb30..c7c3087 100644 --- a/bal/core/checkalive.py +++ b/bal/core/checkalive.py @@ -10,7 +10,7 @@ Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the postpone/invalidate flow; the decision that it *should* be raised lives here. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Any from .plugin_base import BalTimestamp @@ -24,7 +24,7 @@ class CheckAliveError(Exception): def __str__(self): return "Check alive expired please update it: {}".format( - datetime.fromtimestamp(self.timestamp_to_check).isoformat() + datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat() ) @@ -70,7 +70,7 @@ def resolve_date_to_check( The reference timestamp (float, UNIX seconds). """ if is_basic_mode: - return (now if now is not None else datetime.now().timestamp()) + return (now if now is not None else datetime.now(tz=timezone.utc).timestamp()) threshold = BalTimestamp(will_settings["threshold"]) # A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery": @@ -107,5 +107,5 @@ def check_alive_expired( """ if is_basic_mode: return False - current = now if now is not None else datetime.now().timestamp() + current = now if now is not None else datetime.now(tz=timezone.utc).timestamp() return date_to_check < current diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 7ac2f4e..188e73e 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -24,7 +24,7 @@ This module performs **no** GUI work and imports nothing from PyQt / electrum.gu import json import os import platform -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from electrum import constants, json_db from electrum.logging import get_logger @@ -460,8 +460,8 @@ class BalPlugin(BasePlugin): def default_will_settings_absolute(): """Convert the default relative dates into absolute timestamps (from today).""" relative_dates = BalPlugin.default_will_settings_relative() - today = date.today() - dt = datetime(today.year, today.month, today.day, 0, 0, 0) + today = datetime.now(tz=timezone.utc).date() + dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc) threshold = ( dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days()) ).timestamp() @@ -521,12 +521,12 @@ class BalTimestamp: """ int32_max = 2 ** 31 - 1 try: - return datetime.fromtimestamp(ts) + return datetime.fromtimestamp(ts, tz=timezone.utc) except (OSError, OverflowError, ValueError): try: - return datetime.fromtimestamp(min(int(ts), int32_max)) + return datetime.fromtimestamp(min(int(ts), int32_max), tz=timezone.utc) except (OSError, OverflowError, ValueError): - return datetime.fromtimestamp(int32_max) + return datetime.fromtimestamp(int32_max, tz=timezone.utc) def to_date(self, from_date=None, reverse=False): """Resolve to a ``datetime``. @@ -539,7 +539,7 @@ class BalTimestamp: return self._safe_fromtimestamp(self.value) else: if from_date is None: - from_date = datetime.now() + from_date = datetime.now(tz=timezone.utc) if isinstance(from_date, (int, float)): from_date = self._safe_fromtimestamp(from_date) reverse = 1 if not reverse else -1 diff --git a/bal/core/reminders.py b/bal/core/reminders.py index 9e8e677..db8990e 100644 --- a/bal/core/reminders.py +++ b/bal/core/reminders.py @@ -38,7 +38,7 @@ def compute_reminder_offsets(days, count): count: requested number of reminders. Returns: - A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for + A list of integer day-offsets (each ``>= 1``), e.g. ``[30, 16, 1]`` for ``days=30, count=3``. Empty if there is no room for any reminder. """ # No room for any reminder (deadline today or already passed). diff --git a/bal/core/util.py b/bal/core/util.py index 55450bb..dbcf1f8 100644 --- a/bal/core/util.py +++ b/bal/core/util.py @@ -18,7 +18,7 @@ original implementation. """ import bisect -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL from electrum.transaction import PartialTxOutput @@ -103,7 +103,7 @@ class Util: except Exception: pass try: - now = datetime.now() + now = datetime.now(tz=timezone.utc) if locktime[-1] == "y": locktime = str(int(locktime[:-1]) * 365) + "d" if locktime[-1] == "d": @@ -189,7 +189,7 @@ class Util: # moment, so fall back to the legacy forward-from-now resolution. return Util.parse_locktime_string(current) try: - base = datetime.fromtimestamp(int(tx_locktime)).replace( + base = datetime.fromtimestamp(int(tx_locktime), tz=timezone.utc).replace( hour=0, minute=0, second=0, microsecond=0 ) build_moment = base - timedelta(days=built_days) @@ -440,9 +440,9 @@ class Util: # On Windows datetime.fromtimestamp raises OverflowError past 2038 # (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170). try: - dt = datetime.fromtimestamp(locktime) + dt = datetime.fromtimestamp(locktime, tz=timezone.utc) except (OverflowError, OSError, ValueError): - dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) + dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc) dt -= timedelta(seconds=seconds) out = dt.timestamp() @@ -450,34 +450,6 @@ class Util: out = 1 return out - @staticmethod - def cmp_locktime(locktimea, locktimeb): - """Compare two relative locktime strings sharing the same unit.""" - if locktimea == locktimeb: - return 0 - strlocktimea = str(locktimea) - strlocktimeb = str(locktimeb) - if locktimea[-1] in "ydb": - if locktimeb[-1] == locktimea[-1]: - return int(strlocktimea[-1]) - int(strlocktimeb[-1]) - else: - return int(locktimea) - (locktimeb) - - @staticmethod - def get_lowest_valid_tx(available_utxos, will): - """Placeholder kept from the original code (sorts the will by locktime).""" - will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime) - for _txid, _willitem in will.items(): - pass - - @staticmethod - def get_locktimes(will): - """Return the distinct locktimes used by the transactions in ``will``.""" - locktimes = {} - for _, willitem in will.items(): - locktimes[willitem["tx"].locktime] = True - return locktimes.keys() - @staticmethod def get_lowest_locktimes(locktimes): """Split a list of locktimes into (sorted_timestamps, sorted_blocks).""" @@ -492,32 +464,6 @@ class Util: return sorted(sorted_timestamp), sorted(sorted_block) - @staticmethod - def get_lowest_locktimes_from_will(will): - """Convenience wrapper: lowest locktimes directly from a will dict.""" - return Util.get_lowest_locktimes(Util.get_locktimes(will)) - - @staticmethod - def search_willtx_per_io(will, tx): - """Find a will entry whose tx has the same inputs/outputs as ``tx``.""" - for wid, w in will.items(): - if Util.cmp_txs(w["tx"], tx["tx"]): - return wid, w - return None, None - - @staticmethod - def invalidate_will(will): - raise Exception("not implemented") - - @staticmethod - def get_will_spent_utxos(will): - """Collect every input spent by any transaction in ``will``.""" - utxos = [] - for _, willitem in will.items(): - utxos += willitem["tx"].inputs() - - return utxos - # ------------------------------------------------------------------ # # UTXO helpers # ------------------------------------------------------------------ # diff --git a/bal/core/will.py b/bal/core/will.py index 3fb324d..4122b26 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -74,11 +74,6 @@ class Will: if not will[child[0]].father: will[child[0]].father = willid - # return a list of will sorted by locktime - @staticmethod - def get_sorted_will(will): - return sorted(will.items(), key=lambda x: x[1]["tx"].locktime) - @staticmethod def only_valid(will): for k, v in will.items(): @@ -107,15 +102,6 @@ class Will: and not w.get_status("CHECKED") ) - @staticmethod - def search_equal_tx(will, tx, wid): - for w in will: - if w != wid and not tx.to_json() != will[w]["tx"].to_json(): - if will[w]["tx"].txid() != tx.txid(): - if Util.cmp_txs(will[w]["tx"], tx): - return will[w]["tx"] - return False - @staticmethod def get_tx_from_any(x): try: @@ -516,6 +502,7 @@ class Will: for _wid, w in will.items(): if w.get_status("VALID") and not w.get_status("COMPLETE"): return True + return False @staticmethod def search_rai(all_inputs, all_utxos, will, wallet): @@ -1345,6 +1332,8 @@ class WillItem(Logger): WillItem, ): 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: self.tx = Will.get_tx_from_any(w["tx"]) self.heirs = w.get("heirs", None) diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py index ec4979d..e9e1e1b 100644 --- a/bal/gui/qt/calendar.py +++ b/bal/gui/qt/calendar.py @@ -13,12 +13,16 @@ The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder, the Qt button and the OS/subprocess glue. """ +import os +import subprocess + from 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 * -from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import _, _logger class BalCalendarButton(QToolButton): diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 8089770..23e514d 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -22,8 +22,61 @@ from typing import TYPE_CHECKING from ...core.checkalive import CheckAliveError from ...core.reminders import build_ics_reminders from .calendar import BalCalendarButton -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import ( + _, + _logger, + AmountException, + Any, + BalTimestamp, + BestEffortRequestFailed, + Buttons, + Callable, + CancelButton, + HEIR_DUST_AMOUNT, + HEIR_REAL_AMOUNT, + HeirAmountIsDustException, + HeirChangeException, + HeirNotFoundException, + MessageBoxMixin, + Network, + NoHeirsException, + NoWillExecutorNotPresent, + NotCompleteWillException, + QComboBox, + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QTimer, + QVBoxLayout, + QWidget, + Qt, + TaskThread, + TxBroadcastError, + TxFeesChangedException, + Util, + Will, + WillExecutorFeeTooHighException, + WillExecutorNotPresent, + WillExpiredException, + WillPostponedException, + WillexecutorChangeException, + Willexecutors, + bring_to_front, + decimal_point_to_base_unit_name, + import_meta_gui, + partial, + pyqtSignal, + read_QIcon_from_bytes, + read_json_file, + show_modal, + show_on_top, + stop_thread, + time, + top_level_of, +) from .widgets import ( WillSettingsWidget, WillWidget, diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 78a8d15..80069f7 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -19,8 +19,59 @@ from typing import TYPE_CHECKING from PyQt6.QtWidgets import QLineEdit as _QLineEdit from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import ( + _, + _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 .widgets import BalCheckBox, WillSettingsWidget diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index d66f59e..ac1dc92 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -15,14 +15,35 @@ and cached in ``self.bal_windows``. """ from electrum.gui.qt.main_window import StatusBarButton +from electrum.plugin import hook from electrum.util import EventListener, event_listener from PyQt6.QtWidgets import QLayout -from .common import * -from .common import ( # underscore names are not re-exported by "import *" +from .common import ( _, _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 diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 0a2a477..10ccd16 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -28,8 +28,53 @@ from ...core.input_rules import ( ) from ...core.reminders import build_ics_reminders, write_temp_ics from .calendar import BalCalendar, BalCalendarButton -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import ( + _, + _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: from .window import BalWindow diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index a1d955c..789c2b3 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -24,8 +24,63 @@ from ...core.checkalive import ( check_alive_expired, resolve_date_to_check, ) -from .common import * -from .common import _, _logger # underscore names are not re-exported by "import *" +from .common import ( + _, + _logger, + AmountException, + BalPlugin, + Buttons, + CancelButton, + ElectrumWindow, + FileImportFailed, + HeirChangeException, + HeirNotFoundException, + Heirs, + HelpButton, + Mapping, + Network, + NoHeirsException, + NoWillExecutorNotPresent, + NotCompleteWillException, + OP_RETURN_PREFIX, + OkButton, + PaymentIdentifier, + QGridLayout, + QLabel, + QLineEdit, + QPushButton, + QTimer, + QVBoxLayout, + SerializationError, + Transaction, + TxDialog, + TxFeesChangedException, + Util, + Will, + WillExecutorFeeTooHighException, + WillExecutorNotPresent, + WillExpiredException, + WillItem, + WillPostponedException, + WillexecutorChangeException, + Willexecutors, + char_width_in_lineedit, + copy, + export_meta_gui, + import_meta_gui, + is_onion_url, + is_op_return_address, + is_tor_active, + log_error, + partial, + read_QIcon_from_bytes, + read_json_file, + show_on_top, + shown_cv, + time, + tx_from_any, + write_json_file, +) from .dialogs import ( BalBuildWillDialog, BalDialog, diff --git a/bal/manifest.json b/bal/manifest.json index 311eea0..29c3c81 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "fullname": "Bitcoin After Life", - "version": "0.6.1", + "version": "0.7.0", "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.", "author": "Svatantrya", "licence": "MIT", diff --git a/docs/inheritance-options.html b/docs/inheritance-options.html index 82f5386..695da7c 100644 --- a/docs/inheritance-options.html +++ b/docs/inheritance-options.html @@ -308,7 +308,7 @@ executor that should hold your tx did not return it — re‑Broadcast
  • Mind the dust limit. A share below Bitcoin's dust limit is skipped; if every heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.
  • -