forked from bitcoinafterlife/bal-electrum-plugin
Compare commits
7 Commits
bitcoinaft
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| c2eecce029 | |||
|
4b0af6f4bf
|
|||
| f72ed33ea0 | |||
| 6c41a28541 | |||
| 00eefe0525 | |||
|
|
b610bea5a8 | ||
|
|
4ae019cba5 |
161
CHANGELOG.md
161
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, `<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
30
COMPATIBILITY.md
Normal 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
99
COMPATIBILITY_ROADMAP.md
Normal 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.
|
||||
17
HANDOFF.md
17
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,
|
||||
|
||||
13
README.md
13
README.md
@@ -38,10 +38,19 @@ tests/ smoke + external-zip regression tests
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`,
|
||||
which this plugin relies on. Newer versions removed it.
|
||||
- **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
|
||||
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).
|
||||
|
||||
## 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
|
||||
|
||||
### Build the distribution archive
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
0.5.18
|
||||
@@ -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.
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
116
tests/test_version_source.py
Normal file
116
tests/test_version_source.py
Normal 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()
|
||||
Reference in New Issue
Block a user