core+gui: timezone-correct datetimes, deep-copy WillItem, dead code removal, explicit imports

This commit is contained in:
2026-08-19 20:55:53 -04:00
parent 5b109384cf
commit 0bacbf1997
30 changed files with 1435 additions and 10150 deletions

View File

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

View File

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

View File

@@ -58,6 +58,7 @@ bal/ <- the plugin package (this is what ships in the ZI
__init__.py <- package docstring (no version here anymore)
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

View File

@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
that transfer your funds to your heirs if you stop refreshing them
(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

View File

@@ -10,7 +10,7 @@ Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
"""
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,12 +13,16 @@ The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
the Qt button and the OS/subprocess glue.
"""
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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — reBroadcast
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
</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>
</div>

View File

@@ -357,5 +357,5 @@ that limit.
---
*This document reflects the current BAL plugin (v0.6.1). Behaviour is derived
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*

View File

@@ -475,6 +475,27 @@ transactions can have in the WILL tab, on each willexecutor that is online.
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
> ensure the inheritance is correctly executed.
>
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
> flow when Electrum closes.
---
## Auto-rebuild on new transactions
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
> (available in **Tools → Plugins → BAL**, default OFF).
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
the will when new transactions are detected in the wallet (e.g. incoming
payments). The delivery date is anticipated by one day so the new will orphans
the old one on-chain without requiring a manual invalidation. An on-chain
invalidation is only needed when the anticipated locktime crosses the **Check
Alive** threshold (ADVANCED mode only).
This is useful for wallets that receive funds regularly: the inheritance stays
up-to-date without manual intervention.
---
@@ -527,6 +548,32 @@ value.
---
## Command-line / headless usage
BAL can also be used without the Qt GUI, via Electrum's daemon mode. This is
useful for scripting, automation, or running on a headless server.
**Prerequisites:** an Electrum daemon (`electrum daemon -d`) and a loaded wallet
(`electrum load_wallet`).
**Example:**
```bash
electrum daemon -d
electrum load_wallet
electrum bal_heirs_list
electrum bal_will_prepare
electrum bal_will_sign --password '...'
electrum bal_will_broadcast
electrum stop
```
All GUI operations (prepare, sign, broadcast, check, rebuild) are available as
`bal_*` commands. See the full command table in the
[README](../../README.md#command-line--headless-usage).
---
About installing a willexecutor server or collaboration, send your request to:
**info@bitcoin-after.life**

View File

@@ -7,8 +7,7 @@ select =["E", "W", "F", "I", "N", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"bal/gui/qt/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
"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, ...)

10606
tests/karen7

File diff suppressed because one or more lines are too long

View File

@@ -61,14 +61,14 @@ def test_advanced_mode_uses_threshold_absolute():
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
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)
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
@@ -91,7 +91,7 @@ def test_advanced_mode_relative_threshold_anchored_to_locktime():
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
from datetime import datetime, timezone
from bal.core.plugin_base import BalTimestamp
@@ -100,7 +100,7 @@ def test_advanced_mode_relative_threshold_with_relative_locktime():
result = resolve_date_to_check(False, settings, now=fake_now)
# 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))
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
@@ -117,7 +117,7 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen
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 = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
frozen = 1817424000 # frozen tx locktime (2027-08-05 00:00 UTC), built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"}
# On build day the frozen delivery is authoritative: date_to_check is
# frozen - 30d and NEVER drifts, however much later the clock gets.
@@ -136,14 +136,14 @@ def test_advanced_mode_relative_locktime_anchored_to_built_tx():
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""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
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))
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

View File

@@ -97,7 +97,7 @@ def test_relative_days():
def test_resolve_locktime_against_tx_absolute():
"""An absolute current date is returned unchanged (compared vs the tx)."""
frozen = 1817438400
frozen = 1817424000
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
@@ -105,7 +105,7 @@ def test_resolve_locktime_against_tx_absolute():
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 = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
frozen = 1817424000 # 2027-08-05 00:00 UTC, i.e. a tx built 2026-08-05 with "1y"
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
assert resolved == frozen
@@ -113,7 +113,7 @@ def test_resolve_locktime_against_tx_unchanged_relative():
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 = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
frozen = 1817424000 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
assert resolved == frozen + 365 * 86400
@@ -121,7 +121,7 @@ def test_resolve_locktime_against_tx_lengthened():
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 = 1817438400
frozen = 1817424000
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
assert resolved < frozen
@@ -129,7 +129,7 @@ def test_resolve_locktime_against_tx_shortened():
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 = 1817438400
frozen = 1817424000
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
assert isinstance(result, int)
assert result > 1700000000
@@ -319,27 +319,6 @@ def test_anticipate_locktime():
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():
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
# 500000, 900000 are block-height (< THRESHOLD)
@@ -351,18 +330,6 @@ def test_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():
class FakeUtxo:
def to_str(self): return "txid:0"
@@ -518,10 +485,7 @@ if __name__ == "__main__":
test_get_value_amount()
test_chk_locktime()
test_anticipate_locktime()
test_cmp_locktime()
test_get_locktimes()
test_get_lowest_locktimes()
test_get_will_spent_utxos()
test_utxo_to_str()
test_cmp_utxo()
test_in_utxo()

View File

@@ -126,22 +126,6 @@ def test_willitem_str_repr():
# 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():
item1 = _make_willitem_blank()
item2 = _make_willitem_blank()

View File

@@ -77,7 +77,10 @@ def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
})
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
# Set the input value so the balance calculation works.
item.tx.inputs()[0]._trusted_value_sats = value_sats
# Use the name-mangled attribute because tx_from_any creates a
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
# reads __value_sats, not _trusted_value_sats.
item.tx.inputs()[0]._TxInput__value_sats = value_sats
if not valid:
item.set_status("INVALIDATED", True)
return item

View File

@@ -281,10 +281,10 @@ class TestKaren7BuildAndInvalidate:
)
def test_built_tx_has_karen7_heirs(self):
"""The built will contains karen7's four heirs."""
assert len(self.heirs_model) == 4
"""The built will contains karen7's six heirs."""
assert len(self.heirs_model) == 6
assert list(self.heirs_model.keys()) == [
"aaaa", "lucia", "mario", "mario2"
"aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"
]
def test_will_items_are_valid(self):

View File

@@ -570,8 +570,8 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
assert len(h) == 6, f"expected 6 heirs, got {len(h)}"
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.
wallet = MagicMock()

View File

@@ -61,9 +61,9 @@ _VALID_TX_HEX = (
"42146f11ef8414ae929feaafc388ac00000000"
)
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
# will built 2026-08-05 with a "1y" recipe.
_FROZEN = 1817438400
# 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):
@@ -173,15 +173,15 @@ def test_karen7_frozen_delivery_not_expired():
window opens BEFORE the delivery, so the will is never read as expired."""
data = _load_karen7()
will_settings = data["will_settings"]
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime == _FROZEN
assert built_locktime == int(wi.tx.locktime)
date_to_check = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
)
assert int(date_to_check) < _FROZEN
assert int(date_to_check) < built_locktime
# Re-evaluated 10 days later the window is identical (no daily drift).
later = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
@@ -191,24 +191,27 @@ def test_karen7_frozen_delivery_not_expired():
def test_karen7_unchanged_heirs_are_coherent():
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
"""The karen7 heirs (unchanged relative "2d") are coherent with the frozen
signed tx: the plugin must NOT ask to invalidate the will."""
data = _load_karen7()
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
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=int(wi.tx.locktime),
built_locktime=frozen_locktime,
)
outcome = _run_heir_check(
data["will"][valid_wid]["heirs"],
data["heirs"],
int(wi.tx.locktime),
frozen_locktime,
status_complete=True,
)
assert outcome.startswith("coherent"), outcome
assert int(date_to_check) < int(wi.tx.locktime)
assert int(date_to_check) < frozen_locktime
# ------------------------------------------------------------------ #

View File

@@ -392,7 +392,7 @@ class TestNoWillexecutorKaren7:
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
assert len(h) == 6
self.heirs_obj = h
self.bal_plugin = _Karen7BalPlugin()

View File

@@ -170,7 +170,7 @@ def test_simulate_task_phase1():
heirs_data = KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
assert len(h) == 6
# 2. Build UTXOs
utxos = build_utxos(KAREN7_DATA)