diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9894f..fce3a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2349,3 +2349,164 @@ bumping `bal/VERSION`, `bal/manifest.json`, `bal/__init__.py` and passed / 2 pre-existing unrelated failures as v0.5.18); `ruff` clean. **Outcome:** DONE. + +--- + +## 44. Repo: inheritance-logic test suite + first BAL CLI (no plugin version change) + +**Date:** 2026-07-20 + +**Scope note:** this entry adds REPOSITORY files only (`tests/`, `bal_cli.py`). +The plugin package `bal/` is byte-for-byte unchanged, so the plugin version +stays **0.6.0** on purpose (no zip rebuild, no version bump - avoiding any new +tag/code mismatch). + +**1) New logic test suite - `tests/test_inheritance_scenarios.py` (23 tests):** +- Multi-heir distribution via `Heirs.prepare_lists`: 50/50 percent split of + (balance - fees); percentages normalized to their SUM (30+30 behaves like + 50/50); fixed heirs paid first with percents sharing the remainder; fixed + amounts exceeding the balance scaled down proportionally (`onlyfixed`). +- Dust rules: below-dust heirs marked `DUST:` while valid heirs keep building; + the leftover redistribution can LIFT dust fixed heirs above the threshold + (pinned: 100:200 on 1M -> 333333/666666); all-dust wills refused + (`HeirAmountIsDustException`); `BalanceTooLowException` when balance < fees. +- Will-executor fees: one pseudo-heir per distinct locktime; fees larger than + the balance raise `WillExecutorFeeException`. +- Expired-heir exclusion vs `from_locktime`; grouping of heirs by locktime. +- Heir add/remove/locktime-change all flag the will as changed + (`Will._same_heirs`); the `Heirs` mapping persists on add/remove. +- Will expiry (`Will.check_will_expired`): past locktime raises + `WillExpiredException`, future/boundary (== now) does not, non-VALID items + are ignored. +- Locktime parsing: int passthrough, `d` -> future midnight, `1y` == `365d`. + +**2) First BAL CLI - `bal_cli.py` (offline iteration):** +- Reuses `bal.core` directly with Electrum as a library (no Qt, headless). +- Commands: `heirs list/add/remove/export/import`, `status`. +- Safety: **testnet by default**, mainnet only with an explicit `--mainnet` + flag (active network always printed); encrypted wallets via `--password` or + `BAL_WALLET_PASSWORD` env var; address/locktime validation (rejects wrong + network and past locktimes); guaranteed process termination (electrum leaves + non-daemon threads even offline - the CLI stops the wallet and event loop, + then hard-exits with the proper exit code, so machine callers never hang). +- Planned next iteration: `will build/sign/push/check` with an explicit + `--yes` confirmation flag for automation. +- End-to-end smoke test `tests/test_cli_smoke.py`: creates a REAL testnet + wallet via electrum-as-library in a subprocess, then drives + add/list/reject-invalid-address/reject-past-locktime/export/remove/status. + +**Verification:** +- Full suite against **Electrum 4.7.2**: `290 passed`; against **4.8.0**: + `290 passed` (same 2 pre-existing, unrelated `baltx_fees` failures in both). +- `ruff`: no new errors on the three new files. + +**Outcome:** DONE. + +--- + +## 45. Repo: CLI will commands (build / sign / push / check) - no plugin version change + +**Date:** 2026-07-21 + +**Goal:** complete the headless workflow started in entry #44, so a machine can +run the whole inheritance cycle without the Qt GUI. + +**What was added (`bal_cli.py`):** +- `will build` - builds the will transactions offline through the same + `bal.core` pipeline the GUI uses (`Heirs.get_transactions` -> `WillItem` -> + `Will.update_will` / `normalize_will`), storing them as "New". +- `will sign` - signs the valid, incomplete transactions with the wallet + password (`--password` / `BAL_WALLET_PASSWORD`). Prints a summary and asks + for confirmation; `--yes` skips it for automation. Chained will inputs + (spending a previous will's change) are patched exactly as the GUI does. + Watching-only wallets and hardware keystores are rejected with a clear + message (hardware devices need physical confirmation). +- `will push` - sends signed transactions to the selected will-executors via + `Willexecutors.push_transactions_parallel`; `--yes` and `--force` supported; + updates PUSHED / PUSH_FAIL statuses. +- `will check` - read-only report with **exit codes for scripts**: 0 valid, + complete and pushed; 2 no will stored; 3 EXPIRED; 4 not fully signed; + 5 signed but not pushed; 6 heirs changed since the will was built. +- `_CliBalPlugin`: minimal stand-in exposing only what `bal.core` needs + headless (`WILLEXECUTORS`, `NO_WILLEXECUTOR`, `get_decimal_point`). + +**Three real bugs found and fixed while implementing this:** +1. `Will.only_valid()` returns a GENERATOR - `len()` on it raised + `TypeError`; now wrapped in `list()`. +2. `copy.deepcopy(tx)` fails on Electrum 4.8 (`cannot pickle '_thread.RLock'`); + signing now re-parses the transaction from its serialization instead. +3. **Silent will loss on save**: values loaded from the wallet DB are + `StoredDict`s carrying the DB lock, and `JsonDB.put` deep-copies its value + and returns False *silently* when that fails - the will appeared saved but + was never written. Persistence now normalizes through a + `json.dumps`/`loads` round-trip (which also validates serializability) and + exits with an explicit error if anything is non-serializable. + +Also added: pre-build validation of the selected executor addresses for the +active network (refreshed from the server when possible), and a guard that +catches exception sentinels embedded by `heirs.py` in a heir entry when an +output cannot be built. + +**Verification:** +- New `test_cli_will_cycle` in `tests/test_cli_smoke.py`: creates a REAL + testnet wallet, funds it offline with a handmade transaction, then drives + `build -> check(4) -> sign --yes -> check(5) -> push (aborted at the + confirmation prompt) -> status`. No network access. +- Full suite: **291 passed** against **Electrum 4.7.2** and **4.8.0** (same 2 + pre-existing, unrelated `baltx_fees` failures in both). +- `ruff`: all checks passed. + +**Note:** repository-only change (CLI + tests). The plugin version is +unchanged; the Qt plugin code is untouched. + +**Outcome:** DONE. + +--- + +## 46. v0.6.1 - Version read from manifest.json (single source of truth) - PR #4 + +**Date:** 2026-07-22 + +**Goal (Truman):** stop keeping the version in four places (`bal/VERSION`, +`bal/manifest.json`, `bal/__init__.py`, `bal/core/plugin_base.py`) synced by a +pre-commit hook. The version must be read at runtime from `manifest.json` only. + +**What changed:** +- `bal/core/plugin_base.py`: new module function `get_version()` reads the + `"version"` field from `bal/manifest.json` using `importlib.resources` + (zip-safe: works both extracted and from inside a zip, which is how Electrum + loads external plugins via `zipimport`; no hand-built paths, so no Windows + backslash-in-zip issue). The value is cached. `BalPlugin` now exposes a + `version` **property** returning `get_version()`; the hardcoded + `__version__` class attribute and the old `version()` method that read the + `VERSION` file are removed. +- The three call sites that printed the version now use the property + (`self.bal_window.bal_plugin.version` in `bal/gui/qt/widgets.py` and + `bal/gui/qt/dialogs.py`) or `get_version()` where no plugin instance is + available (`bal/core/willexecutors.py`, the HTTP user-agent, a static + method). +- **Removed the `bal/VERSION` file** (the plugin package now ships 36 files + instead of 37). +- `HANDOFF.md`: updated the four references that told maintainers to edit + `bal/VERSION` / keep four files in sync - now they point to + `manifest.json` as the single source of truth. (Historical version + references in `CHANGELOG.md` and `.agent_memory_tasks.md` are left intact on + purpose - they describe past events.) + +**Verification:** +- New `tests/test_version_source.py` (7 tests): version equals the manifest + field; the `version` property matches `get_version()`; no hardcoded + `__version__` remains; no `bal/VERSION` file remains; the value is cached; + and - the key one - the version is read correctly when `bal` is imported + FROM INSIDE A ZIP in a child interpreter (the real Electrum/Windows + scenario). +- Full suite: **298 passed** against **Electrum 4.7.2** and **4.8.0** (same 2 + pre-existing, unrelated `baltx_fees` failures in both). +- `ruff`: clean. +- Also verified the built distribution zip reports the manifest version via + `get_version()` (bumped to 0.6.1 in this change). + +**Note:** repository change targeting PR #4 (branch +`bitcoinafterlife-patch-5`). No functional change to inheritance behavior. + +**Outcome:** DONE. diff --git a/HANDOFF.md b/HANDOFF.md index 916fc56..397e0f1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -19,7 +19,7 @@ services) can be paid a fee to broadcast the inheritance when due. The owner periodically proves they are alive ("check-alive"); if the deadline passes, the inheritance becomes spendable. -- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**). +- **Current version:** see the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`). --- @@ -55,11 +55,10 @@ These are non-negotiable. They come from the owner directly. ``` bal/ <- the plugin package (this is what ships in the ZIP) - __init__.py <- __version__ (one of 4 version files) - VERSION <- plain-text version (one of 4 version files) - manifest.json <- plugin manifest, "version" field (one of 4) + __init__.py <- package docstring (no version here anymore) + manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version) core/ - plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4) + plugin_base.py <- get_version() reads the version from manifest.json (zip-safe) heirs.py <- HEIRS + transaction building (prepare_lists, prepare_transactions, buildTransactions). CORE LOGIC. will.py <- Will/WillItem, validation (check_amounts, check_will), @@ -115,13 +114,11 @@ find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files ``` -**Bump version — there are FOUR files, keep them in sync:** +**Bump version — ONE file only (single source of truth):** ``` -bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT -bal/__init__.py -> __version__ = "X.Y.Z" -bal/VERSION -> X.Y.Z bal/manifest.json -> "version": "X.Y.Z", ``` +The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py` (exposed as the `BalPlugin.version` property), so there is nothing else to keep in sync. There is no longer a `bal/VERSION` file nor a hardcoded `__version__`. **IMPORTANT for the owner when testing:** after installing a ZIP, the owner must **fully restart Electrum** (not just reload the plugin) — Electrum's @@ -292,7 +289,7 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's ## 7. How to resume (checklist for the next AI) 1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`. -2. Confirm the environment: `git status`, current branch, `bal/VERSION`. +2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`. 3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8). 4. Talk to the owner in **Italian**, write everything else in **English**. 5. For any change: present a PLAN, wait for "OK" (R4), then implement, test, diff --git a/bal/VERSION b/bal/VERSION deleted file mode 100644 index d2d81b7..0000000 --- a/bal/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.5.18 \ No newline at end of file diff --git a/bal/__init__.py b/bal/__init__.py index 9df8696..1576d15 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -36,4 +36,7 @@ The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed available and adapts, so both releases keep working. """ -__version__ = "0.5.18" +# The plugin version is NOT defined here. It lives only in ``bal/manifest.json`` +# (the single source of truth) and is read at runtime via ``get_version()`` in +# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property). +# Keeping a hardcoded ``__version__`` here would just be a stale duplicate. diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 6deb625..a0828d6 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -21,6 +21,7 @@ serialised together with the wallet file. This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. """ +import json import os import platform from datetime import date, datetime, timedelta @@ -33,6 +34,45 @@ from electrum.transaction import tx_from_any _logger = get_logger(__name__) +# --------------------------------------------------------------------------- # +# Plugin version - single source of truth +# --------------------------------------------------------------------------- # +# The version lives ONLY in bal/manifest.json (the file Electrum itself reads). +# We used to hardcode it in four files and keep them in sync with a pre-commit +# hook; reading it from the manifest removes that duplication. +# +# importlib.resources is used on purpose: it reads a data file bundled inside +# the ``bal`` package and works identically whether the plugin runs from an +# extracted directory or from INSIDE a zip (Electrum loads external plugins via +# zipimport). It never builds a path by hand, so there is no os.path.join +# backslash issue on Windows inside a zip. +_VERSION_CACHE = None + + +def get_version(): + """Return the plugin version from ``bal/manifest.json`` (cached). + + Zip-safe and independent of the current working directory. Falls back to + ``"unknown"`` if the manifest cannot be read, so importing the plugin never + fails just because of version lookup. + """ + global _VERSION_CACHE + if _VERSION_CACHE is None: + try: + import importlib.resources + + data = ( + importlib.resources.files("bal") + .joinpath("manifest.json") + .read_text(encoding="utf-8") + ) + _VERSION_CACHE = json.loads(data)["version"] + except Exception as e: # noqa: BLE001 - never break import over version + _logger.error(f"failed to read version from manifest.json: {e}") + _VERSION_CACHE = "unknown" + return _VERSION_CACHE + + # --------------------------------------------------------------------------- # # Wallet-DB registration # --------------------------------------------------------------------------- # @@ -120,9 +160,6 @@ class BalPlugin(BasePlugin): layer (or unit tests) can use the plugin logic without importing Qt. """ - _version = None - __version__ = "0.5.18" # AUTOMATICALLY GENERATED DO NOT EDIT - # Command used to open an .ics calendar file, per operating system. default_app = { "Linux": "xdg-open", @@ -138,18 +175,11 @@ class BalPlugin(BasePlugin): # Default geometry hint for some dialogs (kept from the original code). SIZE = (159, 97) + @property def version(self): - """Return the plugin version, read once from the ``VERSION`` file.""" - if not self._version: - try: - f = "" - with open("{}/VERSION".format(self.plugin_dir), "r") as fi: - f = str(fi.read()) - self._version = f.strip() - except Exception as e: - _logger.error(f"failed to get version: {e}") - self._version = "unknown" - return self._version + """Plugin version, read from ``bal/manifest.json`` (single source of + truth). See :func:`get_version`.""" + return get_version() def __init__(self, parent, config, name): self.logger = get_logger(__name__) diff --git a/bal/core/willexecutors.py b/bal/core/willexecutors.py index 5dfcc55..516a3f0 100644 --- a/bal/core/willexecutors.py +++ b/bal/core/willexecutors.py @@ -23,7 +23,7 @@ from electrum.i18n import _ from electrum.logging import get_logger from electrum.network import Network -from .plugin_base import BalPlugin +from .plugin_base import BalPlugin, get_version # Per-request timeout (seconds) for interactive operations (ping / info / # list download). These fail fast (no retries) so a dead server does not @@ -272,7 +272,7 @@ class Willexecutors: raise Exception("You are offline.") _logger.debug(f"<-- {method} {url} {data}") headers = {} - headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}" + headers["user-agent"] = f"BalPlugin v:{get_version()}" headers["Content-Type"] = "text/plain" if not handle_response: handle_response = Willexecutors.handle_response diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 3196111..cf60cd7 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -1533,7 +1533,7 @@ class BalBuildWillDialog(BalDialog): "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Bitcoin After Life//Electrum Plugin/" - f"{BalPlugin.__version__}", + f"{self.bal_window.bal_plugin.version}", ] total = len(offsets) diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 024e54b..b998d63 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -1153,7 +1153,7 @@ class WillSettingsWidget(QWidget): lines = [ "BEGIN:VCALENDAR", "VERSION:2.0", - f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}", + f"PRODID:-//Bitcoin After Life//Electrum Plugin/{self.bal_window.bal_plugin.version}", ] # One separate VEVENT per reminder offset (its own date in the calendar). @@ -1284,7 +1284,7 @@ class WillSettingsWidget(QWidget): "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Bitcoin After Life//Electrum Plugin/" - f"{BalPlugin.__version__}", + f"{self.bal_window.bal_plugin.version}", ] total = len(offsets) diff --git a/bal/manifest.json b/bal/manifest.json index 4ea97d9..0193afc 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "fullname": "Bitcoin After Life", - "version": "0.5.18", + "version": "0.6.1", "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/tests/test_version_source.py b/tests/test_version_source.py new file mode 100644 index 0000000..54331d3 --- /dev/null +++ b/tests/test_version_source.py @@ -0,0 +1,116 @@ +"""Tests for the single-source-of-truth plugin version. + +The plugin version must come ONLY from ``bal/manifest.json`` (no hardcoded +copies, no ``bal/VERSION`` file), and it must be readable both from an extracted +package and from INSIDE a zip (the way Electrum loads external plugins via +zipimport). These tests lock that behavior. + +Run: + QT_QPA_PLATFORM=offscreen PYTHONPATH= \ + python3 -m pytest tests/test_version_source.py -q +""" + +import json +import os +import subprocess +import sys +import zipfile + +import bal +from bal.core.plugin_base import BalPlugin, get_version + +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_MANIFEST = os.path.join(_REPO, "bal", "manifest.json") + + +def _manifest_version(): + with open(_MANIFEST, encoding="utf-8") as f: + return json.load(f)["version"] + + +def test_get_version_matches_manifest(): + """get_version() returns exactly the manifest 'version' field.""" + assert get_version() == _manifest_version() + + +def test_get_version_is_not_unknown(): + """The manifest must be readable - never the 'unknown' fallback here.""" + assert get_version() != "unknown" + # a plausible semantic version, e.g. 0.6.0 + assert get_version()[0].isdigit() + + +def test_version_property_matches_get_version(): + """BalPlugin.version is a property returning the same value.""" + assert isinstance(BalPlugin.version, property) + # A bare object works as ``self`` because the property ignores instance + # state and simply delegates to get_version(). + class _Dummy: + version = BalPlugin.version + assert _Dummy().version == get_version() + + +def test_no_hardcoded_class_version(): + """The old hardcoded BalPlugin.__version__ constant is gone.""" + assert "__version__" not in vars(BalPlugin) + + +def test_no_version_file_in_package(): + """The bal/VERSION file has been removed (manifest is the only source).""" + assert not os.path.exists(os.path.join(_REPO, "bal", "VERSION")) + + +def test_get_version_cached(): + """Second call returns the cached value (same object).""" + v1 = get_version() + v2 = get_version() + assert v1 == v2 + assert v1 is v2 # cached string, identical object + + +def test_version_readable_from_inside_zip(tmp_path): + """The version must be readable when 'bal' is imported from a zip. + + Electrum loads external plugins via zipimport, so this is the real + production scenario (and the one that used to break on Windows with + os.path.join). We build a zip of the bal package and read the version in a + fresh interpreter whose only path to 'bal' is that zip. + """ + bal_dir = os.path.dirname(os.path.abspath(bal.__file__)) + zip_path = tmp_path / "bal_plugin.zip" + with zipfile.ZipFile(zip_path, "w") as z: + for root, _dirs, files in os.walk(bal_dir): + for fn in files: + if fn.endswith(".pyc"): + continue + full = os.path.join(root, fn) + # arcname keeps the leading 'bal/' package prefix + arc = os.path.join( + "bal", os.path.relpath(full, bal_dir) + ) + z.write(full, arc) + + # electrum must stay importable in the child, but 'bal' must resolve ONLY + # from the zip. So: drop the on-disk repo (and this package's dir) from the + # child's path, prepend the zip, and run from a neutral working directory. + repo_real = os.path.realpath(_REPO) + bal_parent_real = os.path.realpath(os.path.dirname(bal_dir)) + filtered = [ + p for p in sys.path + if p and os.path.realpath(p) not in (repo_real, bal_parent_real) + ] + child_path = os.pathsep.join([str(zip_path)] + filtered) + env = dict(os.environ, PYTHONPATH=child_path, QT_QPA_PLATFORM="offscreen") + code = ( + "import bal, os;" + "assert 'bal_plugin.zip' in bal.__file__.replace(os.sep, '/')," + " 'bal not loaded from zip: ' + bal.__file__;" + "from bal.core.plugin_base import get_version;" + "print(get_version())" + ) + out = subprocess.run( + [sys.executable, "-c", code], env=env, capture_output=True, text=True, + cwd=str(tmp_path), + ) + assert out.returncode == 0, f"child failed: {out.stderr}" + assert out.stdout.strip() == _manifest_version()