Compare commits

..

8 Commits

Author SHA1 Message Date
c2eecce029 Merge pull request 'Update bal/core/plugin_base.py' (#4) from bitcoinafterlife-patch-5 into main 2026-07-22 15:43:19 +00:00
4b0af6f4bf v0.6.1: read version from manifest.json (single source of truth)
Read the plugin version at runtime from bal/manifest.json via
importlib.resources (zip-safe, works from inside a zip on Windows).
Removes the four-file duplication (VERSION, __init__.py, plugin_base.py,
manifest.json) and deletes the bal/VERSION file.

- bal/core/plugin_base.py: new get_version() function, BalPlugin.version
  is now a @property, removed hardcoded __version__
- bal/__init__.py: removed __version__, replaced with a comment
- bal/core/willexecutors.py: user-agent uses get_version()
- bal/gui/qt/dialogs.py, widgets.py: call sites use .version property
- tests/test_version_source.py: 7 tests incl. zip-safe subprocess check
- HANDOFF.md: updated to single-source-of-truth documentation
- CHANGELOG.md: entries 43-46
- bal/manifest.json: version bumped to 0.6.1

PR #4 (branch bitcoinafterlife-patch-5).
2026-07-22 11:19:18 -04:00
f72ed33ea0 Merge pull request 'Add COMPATIBILITY_ROADMAP.md - multisig and TrustedCoin analysis' (#6) from SAFE21.io/bal-electrum-plugin:update-compat-docs into main
Reviewed-on: bitcoinafterlife/bal-electrum-plugin#6
2026-07-22 09:16:27 +00:00
6c41a28541 Merge pull request 'Update CHANGELOG.md' (#3) from bitcoinafterlife-patch-6 into main
Reviewed-on: bitcoinafterlife/bal-electrum-plugin#3
2026-07-22 09:16:03 +00:00
00eefe0525 Merge pull request 'Add COMPATIBILITY.md and update README for 4.8.0 support' (#5) from update-compat-docs into main
Reviewed-on: bitcoinafterlife/bal-electrum-plugin#5
2026-07-22 08:55:28 +00:00
bitcoinafterlife
b610bea5a8 Add compatibility roadmap for multisig and TrustedCoin (2FA) wallets 2026-07-19 16:51:16 +02:00
bitcoinafterlife
4ae019cba5 Add COMPATIBILITY.md; update README with 4.8.0 support and wallet compat section 2026-07-19 11:08:09 +02:00
372f3952ca Update CHANGELOG.md
v0.6.0: update changelog
2026-07-17 17:42:13 +00:00
13 changed files with 498 additions and 34 deletions

View File

@@ -2329,3 +2329,184 @@ The request was to make the failure message clearer (no timeout change).
- `ruff`: no new errors. - `ruff`: no new errors.
**Outcome:** DONE (delivered as test ZIP v0.5.18; commit only after confirmation). **Outcome:** DONE (delivered as test ZIP v0.5.18; commit only after confirmation).
---
## 43. v0.6.0 - Version bump for the official repository release
**Date:** 2026-07-17
**Context:** the plugin content of this version is IDENTICAL to v0.5.18 - no code
changes. A release was published on the official repository
(bitcoinafterlife/bal-electrum-plugin) tagged "v0.6.0", but the internal version
files still read "0.5.18" (a human oversight: the release tag was not matched by
a version bump in the code), so Electrum displayed "0.5.18" after installing it.
This entry aligns the internal version with the intended "0.6.0" release tag by
bumping `bal/VERSION`, `bal/manifest.json`, `bal/__init__.py` and
`bal/core/plugin_base.py` from 0.5.18 to 0.6.0. No functional changes.
**Verification:** full test suite against Electrum 4.7.2 and 4.8.0 (same 266
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, `<n>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.

30
COMPATIBILITY.md Normal file
View File

@@ -0,0 +1,30 @@
# Wallet Compatibility
BAL (Bitcoin After Life) builds and signs Electrum transactions using
Electrum's own wallet and signing infrastructure. Its compatibility therefore
depends on the wallet type in use.
| Wallet type | Status | Notes |
|-------------------------------------------------|-----------------------------|-------|
| Standard wallet (single-signature, seed-based) | ✅ Supported | Primary, fully tested target |
| Hardware wallets (Ledger, Trezor, Coldcard, BitBox02, Jade, KeepKey, etc.) | ✅ Supported | Any hardware wallet supported by Electrum itself |
| Multisig wallets | ❌ Not yet supported | Known limitation identified 2026-07-18. Support is planned for a future plugin release. |
| Electrum TrustedCoin (2FA) wallets | ❓ Unknown / unsupported | Known limitation identified 2026-07-18. It has not yet been determined whether or when this will be addressed. |
## What "not supported" means in practice
For multisig and TrustedCoin (2FA) wallets, BAL's behavior has not been
verified and should be considered **unreliable**. Do not rely on BAL to
protect an inheritance set up on one of these wallet types until this document
is updated to mark them as supported.
## Electrum version compatibility
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
and 4.8.0).
## Reporting compatibility issues
If you find a compatibility problem not listed here, please open an issue on
this repository describing the wallet type, Electrum version, and the exact
error or unexpected behavior observed.

99
COMPATIBILITY_ROADMAP.md Normal file
View File

@@ -0,0 +1,99 @@
# Compatibility Roadmap: Multisig and TrustedCoin (2FA) Wallets
Status document, 2026-07-19. Companion to [`COMPATIBILITY.md`](COMPATIBILITY.md):
that file states *what* is supported today; this one explains *why* multisig and
TrustedCoin (2FA) wallets are currently unsupported and *how* support can be
added.
## Root cause (common to both)
BAL currently does two things that are only valid for standard
(single-signature) wallets:
1. **It builds transactions itself** with `PartialTransaction.from_io(...)`
(`bal/core/will.py`), bypassing `wallet.make_unsigned_transaction`.
2. **It signs with a single call**`wallet.sign_transaction(tx, password)`
(`bal/gui/qt/window.py`, `sign_transactions`) — and considers the will ready
when `tx.is_complete()` is true.
On a standard wallet one signature completes the transaction. On multisig and
2FA wallets **one signature is not enough**: the transaction stays incomplete,
is never marked `COMPLETE`, and can never be pushed to will-executors.
A secondary single-sig assumption: `plugin.py` uses `wallet.get_keystore()`
(singular); multisig wallets expose `get_keystores()` (plural).
## Multisig wallets — solvable, medium/large effort
A 2-of-3 multisig wallet typically holds **only one** of the required private
keys locally; the other cosigners hold theirs. `wallet.sign_transaction` adds
the local signature only, and BAL has no flow to collect the missing ones.
**Proposed solution: the standard PSBT coordination round** (the same flow
Electrum itself uses for multisig spending):
1. Build and sign locally as today.
2. If the transaction is not complete, **export the partially-signed
transaction(s)** (file and/or QR) and mark the will with a new status such
as `WAITING_COSIGNERS`.
3. Each cosigner signs in their own Electrum (native feature — no new
software needed on their side).
4. BAL **re-imports and merges the signatures**; once complete, the will is
pushed to will-executors as today.
Notes and caveats:
- **Chained will transactions** (a will tx spending the change of a previous
will tx) remain workable: with segwit, the txid of an unsigned/partially
signed transaction is already stable, so the whole chain can be exported as
a batch of PSBTs in one round.
- **Every rebuild requires a new cosigner round.** Check Alive postponements
and balance-change rebuilds re-sign the will, so each of them needs the
cosigners again. This is inherent to multisig and must be clearly
communicated in the UI.
- Implementation surface: export/import/merge pipeline, GUI for it, the new
status in the transaction list, and tests.
Target: **next plugin release**, as announced.
## TrustedCoin (2FA) wallets — harder, with one blocking unknown
An Electrum 2FA wallet (`Wallet_2fa`, defined in Electrum's `trustedcoin`
plugin) is technically a **2-of-3 multisig whose second signer is the
TrustedCoin server**:
- signing requires a **one-time password (OTP) per transaction**
(`server.sign(short_id, raw_tx, otp)`);
- the server co-signs only transactions that include **its billing fee**,
which Electrum adds inside `Wallet_2fa.make_unsigned_transaction` — a code
path BAL currently bypasses (see root cause #1).
So today: no billing output, no OTP prompt, local signature only → incomplete
transaction.
Even with full integration (building via the wallet's
`make_unsigned_transaction`, adding the OTP prompt flow), one **decisive
unknown** remains: will transactions carry a **locktime years in the future**.
Whether the TrustedCoin server agrees to co-sign a transaction with such a
far-future `nLockTime` is an undocumented server-side policy. If it refuses,
2FA support is **not achievable** without TrustedCoin's cooperation. This is
why `COMPATIBILITY.md` marks 2FA as *unknown*.
**Proposed plan:**
1. **Empirical test on testnet** (cheap, decisive): create a test 2FA wallet,
build a far-future-locktime transaction through the proper 2FA path, and
check whether the server signs it.
2. If it signs → implement support: build via `make_unsigned_transaction`
(billing output included), integrate the OTP prompt, and document that
every rebuild costs one OTP round and TrustedCoin fees.
3. If it refuses → document 2FA as unsupported, with the practical
workaround: Electrum allows disabling 2FA by restoring the wallet from the
full seed, which turns it into a standard wallet — fully supported by BAL.
## Recommended order of work
1. **Multisig first**: deterministic path, standard Electrum tooling, already
announced for the next release.
2. **TrustedCoin empirical test in parallel**: low cost, and its outcome
decides whether 2FA support is feasible at all.

View File

@@ -19,7 +19,7 @@
services) can be paid a fee to broadcast the inheritance when due. The owner 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, periodically proves they are alive ("check-alive"); if the deadline passes,
the inheritance becomes spendable. 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) bal/ <- the plugin package (this is what ships in the ZIP)
__init__.py <- __version__ (one of 4 version files) __init__.py <- package docstring (no version here anymore)
VERSION <- plain-text version (one of 4 version files) manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
manifest.json <- plugin manifest, "version" field (one of 4)
core/ 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, heirs.py <- HEIRS + transaction building (prepare_lists,
prepare_transactions, buildTransactions). CORE LOGIC. prepare_transactions, buildTransactions). CORE LOGIC.
will.py <- Will/WillItem, validation (check_amounts, check_will), will.py <- Will/WillItem, validation (check_amounts, check_will),
@@ -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 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", 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 **IMPORTANT for the owner when testing:** after installing a ZIP, the owner
must **fully restart Electrum** (not just reload the plugin) — Electrum's 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) ## 7. How to resume (checklist for the next AI)
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`. 1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
2. Confirm the environment: `git status`, current branch, `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). 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**. 4. Talk to the owner in **Italian**, write everything else in **English**.
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test, 5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,

View File

@@ -38,10 +38,19 @@ tests/ smoke + external-zip regression tests
## Requirements ## Requirements
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`, - **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
which this plugin relies on. Newer versions removed it. registration API is available (`json_db.register_dict` on 4.7.2,
`stored_dict.register_name` on 4.8.0) and adapts automatically.
- **PyQt6** (bundled with the Electrum desktop GUI). - **PyQt6** (bundled with the Electrum desktop GUI).
## Wallet compatibility
BAL currently supports **standard (single-signature) wallets** and
**hardware wallets** supported by Electrum. **Multisig wallets** and
**Electrum TrustedCoin (2FA) wallets** are **not yet supported** — see
[`COMPATIBILITY.md`](COMPATIBILITY.md) for the full compatibility matrix and
current status.
## Installation ## Installation
### Build the distribution archive ### Build the distribution archive

View File

@@ -1 +0,0 @@
0.5.18

View File

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

View File

@@ -21,6 +21,7 @@ serialised together with the wallet file.
This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
""" """
import json
import os import os
import platform import platform
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -33,6 +34,45 @@ from electrum.transaction import tx_from_any
_logger = get_logger(__name__) _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 # Wallet-DB registration
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -120,9 +160,6 @@ class BalPlugin(BasePlugin):
layer (or unit tests) can use the plugin logic without importing Qt. layer (or unit tests) can use the plugin logic without importing Qt.
""" """
_version = None
__version__ = "0.6.0" # AUTOMATICALLY GENERATED DO NOT EDIT
# Command used to open an .ics calendar file, per operating system. # Command used to open an .ics calendar file, per operating system.
default_app = { default_app = {
"Linux": "xdg-open", "Linux": "xdg-open",
@@ -138,18 +175,11 @@ class BalPlugin(BasePlugin):
# Default geometry hint for some dialogs (kept from the original code). # Default geometry hint for some dialogs (kept from the original code).
SIZE = (159, 97) SIZE = (159, 97)
@property
def version(self): def version(self):
"""Return the plugin version, read once from the ``VERSION`` file.""" """Plugin version, read from ``bal/manifest.json`` (single source of
if not self._version: truth). See :func:`get_version`."""
try: return get_version()
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
def __init__(self, parent, config, name): def __init__(self, parent, config, name):
self.logger = get_logger(__name__) self.logger = get_logger(__name__)

View File

@@ -23,7 +23,7 @@ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import Network from electrum.network import Network
from .plugin_base import BalPlugin from .plugin_base import BalPlugin, get_version
# Per-request timeout (seconds) for interactive operations (ping / info / # Per-request timeout (seconds) for interactive operations (ping / info /
# list download). These fail fast (no retries) so a dead server does not # list download). These fail fast (no retries) so a dead server does not
@@ -272,7 +272,7 @@ class Willexecutors:
raise Exception("You are offline.") raise Exception("You are offline.")
_logger.debug(f"<-- {method} {url} {data}") _logger.debug(f"<-- {method} {url} {data}")
headers = {} headers = {}
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}" headers["user-agent"] = f"BalPlugin v:{get_version()}"
headers["Content-Type"] = "text/plain" headers["Content-Type"] = "text/plain"
if not handle_response: if not handle_response:
handle_response = Willexecutors.handle_response handle_response = Willexecutors.handle_response

View File

@@ -1533,7 +1533,7 @@ class BalBuildWillDialog(BalDialog):
"BEGIN:VCALENDAR", "BEGIN:VCALENDAR",
"VERSION:2.0", "VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/" "PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{BalPlugin.__version__}", f"{self.bal_window.bal_plugin.version}",
] ]
total = len(offsets) total = len(offsets)

View File

@@ -1153,7 +1153,7 @@ class WillSettingsWidget(QWidget):
lines = [ lines = [
"BEGIN:VCALENDAR", "BEGIN:VCALENDAR",
"VERSION:2.0", "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). # One separate VEVENT per reminder offset (its own date in the calendar).
@@ -1284,7 +1284,7 @@ class WillSettingsWidget(QWidget):
"BEGIN:VCALENDAR", "BEGIN:VCALENDAR",
"VERSION:2.0", "VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/" "PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{BalPlugin.__version__}", f"{self.bal_window.bal_plugin.version}",
] ]
total = len(offsets) total = len(offsets)

View File

@@ -1,7 +1,7 @@
{ {
"name": "bal", "name": "bal",
"fullname": "Bitcoin After Life", "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.", "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
"author": "Svatantrya", "author": "Svatantrya",
"licence": "MIT", "licence": "MIT",

View File

@@ -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=<electrum-src> \
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()