diff --git a/QML_PLAN.md b/QML_PLAN.md deleted file mode 100644 index be00a56..0000000 --- a/QML_PLAN.md +++ /dev/null @@ -1,379 +0,0 @@ -# QML PLAN — BAL on Electrum QML / Android (Option B: minimal viable support) - -> Goal: let Android users (Electrum QML GUI) use BAL. **The Android device is -> the OFFLINE SIGNING DEVICE**: its primary job is to receive unsigned will -> transactions from an online machine (desktop/another phone), sign them with -> the wallet keys held on it, and return the signed transactions — a classic -> air-gapped signer workflow. Online features (willexecutor contact, -> broadcast, build) are secondary on Android and belong mainly to the online -> machine. -> Strategy: a *third frontend* (`bal/gui/qml/`) that reuses `bal/core` logic -> through the existing GUI-free `BalController`, exactly like `bal/cli/` -> already does. The PyQt6 desktop GUI remains untouched and primary. -> -> Status: DRAFT for owner review (rule R4 — no code until explicit OK). -> Chat language: Italian; this document is in English per rule R1. - ---- - -## 1. Verified facts (research done on the local checkouts) - -All items below were verified by reading source, not assumed. - -| # | Fact | Where | -|---|------|-------| -| F1 | The fork's Electrum ships a full QML GUI built on **PyQt6.QtQml** (PyQt6 6.11 installed in runtime env imports `QtQml`/`QtQuick` fine). | `electrum/electrum/gui/qml/` | -| F2 | The QML GUI has a **plugin mechanism**: manifest `"available_for"` must contain `"qml"`; Electrum then loads `/qml.py` and calls the `init_qml(app)` hook. | `electrum/plugin.py` (`load_plugin_by_name`, gui_name), `gui/qml/__init__.py:88` | -| F3 | On load, `main.qml` reads `plugin.so.loader` and auto-creates the component from `//qml/.qml`. The plugin itself sets `.so` (a `PluginQObject`). Canonical example: `electrum/plugins/labels/qml.py`. | `gui/qml/components/main.qml` (`onPluginLoaded`), `gui/common_qt/plugins.py` | -| F4 | The plugin must support **both** target versions: the QML GUI + `common_qt/plugins.py` exist in the 4.7.x line too (verified on the local 4.7.0 checkout). Exact 4.7.2 parity is Phase-0 task T1. | `/home/steal/devel/bal/electrum470/electrum/gui/{qml,common_qt}` | -| F5 | `BalController(plugin, wallet)` is GUI-free, per-wallet, and already implements state init + sign/build/broadcast flows against the bare `wallet` object (no `ElectrumWindow` needed). This is the reuse cornerstone of this plan. | `bal/cli/controller.py:119-175`, `sign_transactions` at :646 | -| F6 | In the repo, `bal` is already symlinked into the Electrum tree as an **internal** plugin: `electrum/electrum/plugins/bal -> ../../../bal-electrum-plugin/bal`. Internal plugins are plain files on disk → the QML engine can load `.qml` assets directly. | `ls -la electrum/electrum/plugins/` | -| F7 | The APK build spec lists packaged plugins explicitly and **BAL is not yet in that list**. | `electrum/contrib/android/buildozer_qml.spec:34-48` | -| F8 | The QML Preferences page has **hardcoded toggles only** for `labels` and `psbt_nostr`; there is no generic plugin manager UI. Plugin enabling works via config regardless (`plugins.bal.enabled = true`). | `gui/qml/components/Preferences.qml:168,186,511-512` | -| F9 | Extension points inside the QML app are minimal: `run_hook('init_qml', app)`, `run_hook('load_wallet', wallet)` (**one** argument, unlike Qt's two), `get_tx_extra_fee`, `tc_sign_wrapper`, and one named-component injection slot (`pluginsComponentsByName('export_tx_button')`). No tools menu, no status bar. | grep over `gui/qml/*.py`, `main.qml:778` | -| F10 | External ZIP plugins cannot serve `.qml` files from inside the zip (zipimport exposes Python modules only; `Qt.resolvedUrl` needs real disk paths). Distribution as internal plugin (F6) or runtime extraction avoids this. | consequence of F3 | -| F11 | Core signing path used by the CLI controller calls `wallet.sign_transaction(tx, password)` and updates signature counts — identical flow works under `QEWallet.wallet`. | `bal/cli/controller.py:646-700` | - ---- - -## 2. Scope - -### In scope (MVP) - -Two usage **profiles** share one codebase: - -- **Offline signer profile** (Android, PRIORITY): works with no network. - 1. Import a will bundle exported by the online machine (existing JSON will - format, see F12) — via file share, paste, or QR. - 2. Review what is being signed (destinations, amounts, locktimes, fees). - 3. Sign internally (`wallet.sign_transaction` + password prompt). - Partial signatures are combined when the bundle is re-imported - (`combine_with_other_psbt`, already supported by `merge_will` logic). - 4. Export the signed bundle back to the online machine. -- **Online manager profile** (desktop QML, secondary): - - Will status overview (state, expiry/check-alive date, reminder info). - - Heirs list (view/add/edit/remove, addresses or URIs). - - Will-executor selection (list, enable/disable, fee display, refresh). - - Build will (simplified wizard reusing core validation). - - Sign in place (password) or hand off to an offline signer via bundles. - - Broadcast / push to will-executors; invalidate will; check-alive refresh. - - Basic settings mapped onto `will_settings`. - -The offline signer pages are built first and must function with the network -disabled (Electrum runs fine offline; willexecutor refresh simply degrades). - -Also in scope: - -- Android packaging: BAL bundled as internal plugin in the custom APK, - **enabled by default** (owner decision D3). -- Keep desktop (`qt`) and CLI (`cmdline`) behavior byte-for-byte unchanged. - -### Out of scope (explicitly deferred) - -- Full parity with the PyQt6 GUI (calendar widget, preview list editor, - advanced fee controls, themes). -- External-ZIP distribution of QML assets (F10 workaround postponed; ZIP - builds keep working for desktop exactly as today, without `qml` UI). -- iOS, upstream-Electrum (spesmilo) compatibility. -- Lightning-related features (irrelevant to BAL). - ---- - -## 3. Architecture - -``` - ┌───────────────────────────────────────────┐ - │ bal/core │ - │ heirs, will, willexecutors, checkalive, │ - │ reminders, input_rules, plugin_base │ - └────────────┬──────────────────────────────┘ - │ (no Qt anywhere) - ┌─────────────────────┼──────────────────────┐ - ▼ ▼ ▼ - bal/gui/qt/ bal/cli/ bal/gui/qml/ ← NEW - BalWindow etc. BalController qml_plugin.py (BalQmlPlugin) - (~9.3k lines) (headless flows) models.py (QObject VMs) - so.py (PluginQObject) - *.qml (views) - ▲ - wraps ONE BalController - per loaded wallet -``` - -Design rules: - -- **Reuse, do not duplicate.** `bal/gui/qml/models.py` holds thin QObject - wrappers around one `BalController` instance per wallet. No business logic - in QML or in the wrappers beyond formatting. -- **Same persistence.** Wallet DB dicts (`heirs`, `will`, `will_settings`) - are registered by `bal/core/plugin_base.py` already; QML reads/writes them - through the controller, so a wallet moves between desktop/Android unchanged. -- **Threading.** Network operations (willexecutor fetch/push, broadcast) - run in worker threads exactly as the CLI does; results marshalled to the UI - thread via Qt signals on the wrapper objects. No blocking calls in slots. -- **One transfer format.** The airgap round trip reuses the existing JSON - will serialization (`WillItem.to_dict()` maps, exactly what the Qt GUI's - `export_json_file`/`import` + `merge_will` flow already produces and - consumes — see F12). No new format is invented; export/merge logic gets a - single shared home usable by both frontends. -- **Version gating.** Every import of `electrum.gui.qml.*` happens lazily and - defensively; if absent (e.g., odd build), the plugin degrades to core-only - behavior instead of crashing the daemon. - ---- - -## 4. Work breakdown - -### Phase 0 — Verification spikes (no product code) - -| Task | Description | Exit criterion | -|------|-------------|----------------| -| T1 | Diff `gui/qml` + `gui/common_qt` between the 4.7.0 checkout here and current 4.8.x, focused on: `PluginQObject`, `init_qml` hook call sites, `onPluginLoaded` handler, `load_wallet` hook arity. If 4.7.2 differs, note shims needed. | Written compatibility note appended to COMPATIBILITY.md draft section | -| T2 | Run desktop QML GUI headless with BAL enabled via config: `QT_QPA_PLATFORM=offscreen run_electrum -g qml` with `plugins.bal.enabled=true`, manifest updated ad-hoc (throwaway branch). Confirms discovery/loading path end-to-end before writing any code. | Log shows `init_qml` called for bal; no crash | -| T3 | APK feasibility: add `electrum/plugins/bal` to `buildozer_qml.spec` package list locally, confirm p4a includes `.qml` data files and icons (may need `source.include_exts` adjustment). Do NOT ship. | Test APK contains `plugins/bal/qml/*.qml` | -| T4 | Decide entry-point UX given F9 (no menu hook): candidate = tiny patch in fork's `main.qml` adding a "BAL" item in the wallet drawer/menu that opens our window object from `app.pluginobjects['bal']`. Confirm with owner. | Decision recorded in this file | - -Deliverable: short findings report appended to this document; go/no-go. - -### Phase 1 — Skeleton integration - -Files (all NEW unless noted): - -``` -bal/qml.py zipimport-style shim mirroring qt.py/cmdline.py -bal/gui/qml/__init__.py package docstring -bal/gui/qml/qml_plugin.py class BalQmlPlugin(BalPluginBase) -bal/gui/qml/so.py class BalSignalObject(PluginQObject) -bal/manifest.json MODIFIED: available_for += ["qml"] -``` - -Details: - -- `qml_plugin.py`: - - `@hook init_qml(self, app)`: store app ref; create `so` parented to app; - for each already-loaded wallet call `_on_wallet_loaded(wallet)` - (mirrors labels' pattern, see F3). - - `@hook load_wallet(self, wallet)` — **single argument** (F9); creates the - per-wallet view-model bundle (Phase 2) keyed by `wallet`. - - `@hook unload_wallet(self, wallet)`: drop controllers, close windows. -- `so.py`: `BalSignalObject(PluginQObject)` exposing: - - `loader` property returning `"BalMain.qml"` (drives F3 auto-create); - - signals: `walletChanged`, `willStateChanged`, `heirsChanged`, - `willexecutorsChanged`, `busyChanged`; - - slots called from QML: open/close window, refresh willexecutors, - check-alive now, build/sign/broadcast/invalidate commands. -- `manifest.json`: append `"qml"` to `available_for`. Desktop untouched - (Electrum filters per running GUI, verified F2). - -Exit criterion: with `-g qml`, plugin loads, `so.loader` component is created -(log line from `onPluginLoaded`), no functional UI yet. - -### Phase 2 — View-models (QObject layer) - -New file `bal/gui/qml/models.py`: - -- `BalQmlWallet(QObject)`: owns one `BalController`; exposes read-only - properties (`willState`, `dateToCheck`, `expired`, `reminderInfo`, - `sigsHave/sigsRequired` per tx) + notification signals; forwards actions to - controller methods (`build_will`, `sign_transactions`, `broadcast_will`, - `invalidate_will_headless`, `check_alive`... — names per controller). - - **Airgap methods (priority):** `export_will_bundle()` and - `import_will_bundle(json_text)` returning summary of what changed. These - are small ports of the Qt GUI's `export_json_file` (window.py:1605) and - `merge_will` (window.py:1620) semantics. Preferred implementation: move - the logic into shared helpers (controller level or `bal/core/will.py` - static functions) and make the Qt GUI call the same helpers, so the two - frontends cannot diverge; regression-covered by existing core tests plus - new round-trip tests. - - Offline profile detection: expose an `isOffline` property derived from - `wallet.network is None` / config, so QML can hide online-only pages. -- `HeirListModel(QAbstractListModel)`: roles `name`, `address`, `amountPct`, - `valid`; edit methods delegate to `Heirs` helpers through controller. -- `WillTxListModel(QAbstractListModel)`: roles `txid`, `status`, `fee`, - `sigsHave`, `sigsRequired`, `isComplete`. -- `WillExecutorListModel(QAbstractListModel)`: roles `url`, `selected`, - `fee`, `valid`; toggle + async refresh. -- `BalQrTransferModel(QObject)`: thin scheduler over the shared transfer - planner `bal.core.qrtransfer` (already battle-tested by the desktop Qt - plugin, P1-P4). Exposes `encode(items)` → frames, `frameAt(i)` (data URL / - pixmap for QML), `decode(text)` → tx list, `total`, `current`, presets; - re-emits a `frameChanged` notifier so the QML page can step 1..N. No QR - rendering inside the model (QML paints it). -- All list mutations happen on the controller state then `beginResetModel/ - endResetModel` (datasets are small; simplicity over incremental updates). - -Exit criterion: pytest-driven model tests pass offscreen (create models over a -regtest/testnet wallet fixture, assert roles after mutations). - -### Phase 3 — QML views - -New directory `bal/gui/qml/components/`: - -``` -BalMain.qml top-level Window; stack of pages below -BalSignPage.qml PRIORITY (offline signer): import bundle - (paste / file / QR), review summary of each tx, - password-sign, export signed bundle back -BalQrExportPage.qml QR export view: drives BalQrTransferModel, one - frame at a time (Prev/Next, progress i/N, chunk - preset selector), mirror of desktop Qt dialog -BalQrImportPage.qml QR import view: slot grid (1..N), camera via - Electrum `QRScan` reuse, manual paste fallback, - then jump into BalSignPage review/sign -BalStatusPage.qml will state, expiry countdown, check-alive button - (works offline with last-known data) -BalHeirsPage.qml ListView + add/edit dialog [online profile] -BalExecutorsPage.qml ListView with switches + refresh [online profile] -BalBuildPage.qml simplified build form (threshold selector, fees, - executor pick) → runs controller.build_will - [online profile] -BalSettingsPage.qml maps onto will_settings subset -controls/BalButton.qml, BalField.qml minimal styled primitives -qmldir module registration -``` - -Notes: - -- **BalSignPage review screen is mandatory**: before signing, the user must - see per-transaction heir address, amount, locktime (date), and fee — this - device is the security boundary, so no "blind signing". -- QR transfer: a full will bundle may exceed one QR's capacity when there are - many heirs/transactions. The desktop Qt plugin now resolves this with - chunked multi-QR streams (`bal/core/qrtransfer.py`, chunk presets - 150/400/900/1800 bytes/frame, EC level M); the QML GUI reuses the same - scheduler via a thin model. Order of preference stays (1) share/save file + - paste text; (2) chunked QR streams through `BalQrTransferModel`. -- Styling minimal, follow existing QML components' look (reuse - `controls/` from Electrum where importable — prefer copying tiny primitives - to avoid coupling to upstream churn; decide during implementation). -- Every string wrapped with `electrum.i18n._`. - -Exit criterion: full manual walkthrough on desktop QML GUI (offscreen + -interactive) performing BOTH: (a) online profile — configure heirs → select -executors → build → sign → broadcast → invalidate; and (b) offline signer -profile — export unsigned bundle from an online wallet, import into a second -offline wallet instance, sign, re-export, import signed bundle back into the -first wallet and verify signatures combined/status COMPLETE. Walkthrough (b) -must pass with networking disabled. - -### Phase 4 — Android integration - -- Add `electrum/plugins/bal` (+ data extensions for `.qml`, `icons/*`) to - `contrib/android/buildozer_qml.spec` in the Electrum fork. -- Patch fork's `Preferences.qml` with a BAL toggle **(owner approved, D1)** - and default-enable BAL in the APK build (D3: enabled by default). -- Entry point per T4 decision (menu/drawer patch in fork's `main.qml`, D1 - approved). Fallback if T4 picks auto-open: window opens on wallet load. -- Rebuild APK; smoke-test on device/emulator: - install → BAL already enabled → open wallet → full MVP walkthrough, - including airplane-mode signing round trip (file/QR transfer between an - online desktop and the offline device — for emulator testing, "offline" = - network disabled via settings). -- Watch-outs: filesystem paths (use `os.path.join`, no hardcoded separators — - already house style), background network on mobile (willexecutor timeouts), - APK size impact (bal is small; icons only), share-intent/file access - permissions for bundle import/export. - -Exit criterion: signed test APK passes the same walkthrough as Phase 3. - -### Phase 5 — Tests & CI hygiene - -- New tests following repo conventions (`def test_*` + `__main__` block): - - `tests/test_qml_models.py` — models over fake/controller-backed wallet - (offscreen, no network). - - `tests/test_qml_airgap_roundtrip.py` — PRIORITY: export unsigned bundle - from wallet A → import into wallet B (same seed, offline) → sign → - export signed → merge back into A; assert COMPLETE status and signature - counts. Must pass with no network. - - `tests/test_qml_plugin_loading.py` — plugin instantiates under a stubbed - QML app object; `so` wiring correct; load/unload wallet lifecycle. - - Extend `tests/smoke_test.py` usage: `QT_QPA_PLATFORM=offscreen python3 - tests/smoke_test.py electrum.plugins.bal` still green (qt path intact). -- Regression gate: full `tests/test_core_*.py` batch + ruff (no NEW - violations) before any delivery ZIP. -- Manual matrix recorded in CHANGELOG entry: [desktop qt, desktop qml - offscreen, Android APK] × [4.7.2, 4.8.0] where applicable. - -### Phase 6 — Release plumbing & docs - -- `build_zip.py`: ensure new `bal/gui/qml/**` and `components/*.qml` included - in deterministic zip (harmless on desktop; enables future extraction-based - loading). -- `COMPATIBILITY.md`, `README.md`, `HANDOFF.md`: document QML/Android status, - limitations, and how to enable (`-g qml` / APK toggle). -- `CHANGELOG.md`: numbered entry at END per house rules. -- Version bump + release handled by `make-release.sh` as usual (owner-driven). - ---- - -## 5. Risks & mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| R1: 4.7.2 vs 4.8 QML internals drift | broken load on one version | Phase-0 T1 diff first; lazy imports + capability checks; shim module if needed | -| R2: no generic plugin UI in QML (F8) | users can't enable BAL from UI | config default-enable in fork APK; small Preferences.qml patch in fork (we control it); document manual config for desktop | -| R3: no natural entry point in main.qml (F9) | user can't find/open BAL window | T4 decision: fork-side menu/drawer patch; fallback = auto-open window on wallet load behind a setting | -| R4: dual-GUI maintenance burden | long-term cost | strict reuse of `BalController`; QML layer forbidden from business logic (review rule); parity features stay in qt GUI | -| R5: threading bugs on mobile networks | ANRs/crashes | all network ops in threads like CLI; signals-only UI updates; timeouts already configurable | -| R6: airgap transfer friction (bundle size vs QR capacity, share permissions on Android) | users cannot move bundles reliably | desktop Qt: chunked multi-QR streams + audio-modem optional channel shipped (P1-P4) and regression-gated; QML: file share + paste first, QR via `BalQrTransferModel` (Phase 2/3 notes); Android camera on Qt6 is known-flaky, file/paste stays the primary mobile fallback and is tested first in Phase 4 | -| R7: export/merge semantics divergence between frontends | signed bundles rejected or double-counted | single shared helper used by qt GUI and qml layer (Phase 2); round-trip regression test | -| R8: hidden coupling of qt code into shared modules | qml import pulls QtWidgets | lint guard idea: import-linter/ruff rule forbidding `PyQt6.QtWidgets` under `bal/gui/qml/` | -| R9: zip distribution ambiguity (F10) | confusion about what ships where | clear policy: ZIP = desktop qt+cmdline only; QML requires internal-plugin/APK route (Phase 6 documents this) | -| R10: unknown Android/Electrum baseline (owner to confirm, OQ4) | wrong Qt/PyQt6 assumptions in APK build | Phase-0 T3 builds against the fork's current toolchain; code keeps 4.7.x/4.8.x dual support so the answer can arrive late without rework | - ---- - -## 6. Questions for the owner — ANSWERED (2026-08-25) - -1. **Fork patches (T4/R3):** ✅ **D1 — APPROVED.** Patching the fork's - `main.qml`/`Preferences.qml` is allowed for the BAL menu entry and toggle. -2. **Offline signing topology:** ✅ **D2 — Android IS the offline device.** - The phone holds the keys and acts as air-gapped signer: import unsigned - bundle → review → sign → export signed bundle back to the online machine. - The sign/import/export page is therefore the top priority of Phase 3, and - the round-trip test is the top priority of Phase 5. -3. **Enable-by-default:** ✅ **D3 — BAL pre-enabled in the custom APK** - (toggle still available to disable). -4. **Target Android/Electrum baseline:** ⏳ **OPEN (OQ4)** — owner will get - back later. Not blocking: see risk R10 mitigation. - ---- - -## 7. Effort estimate - -| Phase | Rough size | -|-------|-----------| -| 0 spikes | ~half day (mostly reading + one throwaway branch) | -| 1 skeleton | ~300 lines Python | -| 2 models (incl. shared export/merge helpers) | ~600–800 lines Python | -| 3 views (offline signer page first) | ~900–1300 lines QML | -| 4 android | fork-side patches + build iteration (device-dependent) | -| 5 tests | ~500 lines | -| 6 release/docs | small | - -Overall: comparable to a medium feature, dominated by Phase 3 UI polish and -Phase 4 device iteration. - ---- - -## 8. Findings log (append-only) - -- **F12** — The airgap round trip already exists in the Qt GUI layer and can - be ported almost verbatim: `export_json_file()` (window.py:1605) exports - all will items as `{wid: WillItem.to_dict()}` JSON (marking them - `EXPORTED`); the import side goes through `merge_will()` - (window.py:1620), which carries operational statuses, combines partial - signatures via `tx.combine_with_other_psbt()` when txids match, substitutes - the tx otherwise, and recomputes validity locally without network. - Conclusion: no new transfer format is needed; the plan is to give this - logic a shared home (controller/core) so Qt, CLI-adjacent tooling and QML - all use one implementation. - -### Decisions - -- **D1** — Fork-side patches to `main.qml` / `Preferences.qml` approved by - the owner. -- **D2** — Android = offline signing device; sign/import/export flow has - top priority. -- **D3** — BAL enabled by default in the custom APK. -- **OQ4** — Android/Electrum baseline: open, non-blocking (see R10). diff --git a/bal/cli/controller.py b/bal/cli/controller.py index dbd4e1c..6e96ac7 100644 --- a/bal/cli/controller.py +++ b/bal/cli/controller.py @@ -43,6 +43,7 @@ from ..core.checkalive import ( CheckAliveError, check_alive_expired, resolve_date_to_check, + resolve_guard_threshold, ) from ..core.heirs import Heirs, is_op_return_address from ..core.plugin_base import BalConfig, BalPlugin @@ -314,6 +315,28 @@ class BalController: executor. """ will = {} + # Drop stale wallet-LOCAL will placeholders (mirror of the GUI + # build_will) so their coins are available to this build. + Will.remove_stale_wallet_history( + self.wallet, self.plugin.HISTORY_LABEL.get() + ) + # A (re)build may have anticipated the delivery (shorter heir recipes) + # while ``date_to_check`` is still anchored to the OLD built will. + # Recompute it for the will being built (earliest future delivery among + # the CURRENT heirs), mirroring ``BalWindow.build_will``, so the + # anticipated dates pass the build filter. + _new_locktime = min( + ( + Util.parse_locktime_string(h[2]) + for h in self.heirs.values() + ), + default=None, + ) + if _new_locktime: + self.date_to_check = resolve_date_to_check( + self.plugin.is_basic_mode(), self.will_settings, + built_locktime=_new_locktime, + ) self.willexecutors = Willexecutors.get_willexecutors( self.plugin, update=False, task=False ) @@ -434,7 +457,13 @@ class BalController: raise _user_facing(e) from e locktime = Util.parse_locktime_string(self.will_settings["locktime"]) - if locktime < date_to_check: + threshold_ts = resolve_guard_threshold( + self.plugin.is_basic_mode(), self.will_settings + ) + if threshold_ts is not None: + if locktime < threshold_ts: + raise UserFacingException(_("locktime is lower than threshold")) + elif locktime < date_to_check: raise UserFacingException(_("locktime is lower than threshold")) if not self.no_willexecutor: diff --git a/bal/core/checkalive.py b/bal/core/checkalive.py index c7c3087..51f63c0 100644 --- a/bal/core/checkalive.py +++ b/bal/core/checkalive.py @@ -96,6 +96,54 @@ def resolve_date_to_check( return threshold.to_timestamp() +def resolve_guard_threshold( + is_basic_mode: bool, + will_settings: Any, + now: float | None = None, +) -> float | None: + """Resolve the "locktime is lower than threshold" guard's reference. + + The guard compares the stored settings on ONE reference frame: the + delivery (``locktime``, kept as at the call site) against this threshold. + + Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the + built will's frozen tx locktime so that an unchanged will never reads as + expired -- this helper resolves the threshold from the **stored settings + alone**. Otherwise, when the stored relative locktime is shorter than the + frozen locktime of an old (still valid) built will (e.g. the delivery was + shortened from ``"2y"`` to ``"1y"``), the guard would compare the fresh + "1y" locktime against the old will's anchored threshold and wrongly fire, + even though locktime > threshold by the settings themselves. + + * BASIC mode: no threshold exists. Returns ``None`` and the caller falls + back to comparing the locktime against ``date_to_check`` (= now), so its + behaviour is unchanged. + * ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold + as-is. + * ADVANCED mode with a RELATIVE threshold (``"30d"``/``"1y"``, meaning + "N days BEFORE the delivery"): the threshold is anchored to the locktime + resolved forward from *now* (the settings' own delivery reading, never a + built tx), keeping both sides of the comparison in the same reference + frame, as the settings widget displays it. + + Returns ``None`` when there is no threshold to enforce (BASIC mode or a + missing stored value). + """ + if is_basic_mode: + return None + threshold_raw = will_settings.get("threshold") + if threshold_raw is None: + return None + threshold = BalTimestamp(threshold_raw) + if threshold.unit is None: + return threshold.to_timestamp() + now_dt = ( + datetime.fromtimestamp(now, tz=timezone.utc) if now is not None else None + ) + locktime_dt = BalTimestamp(will_settings["locktime"]).to_date(now_dt) + return threshold.to_date(locktime_dt, reverse=True).timestamp() + + def check_alive_expired( is_basic_mode: bool, date_to_check: float, now: float | None = None ) -> bool: diff --git a/bal/core/will.py b/bal/core/will.py index 1dcb137..71d7ff1 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -28,6 +28,7 @@ The status flags themselves (the source of truth) stay here; only the mapping from datetime import datetime, timezone +from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL from electrum.i18n import _ from electrum.logging import Logger, get_logger from electrum.transaction import ( @@ -847,6 +848,48 @@ class Will: except Exception as e: _logger.error(f"save_valid_transactions_to_history failed: {e}") + @staticmethod + def remove_stale_wallet_history(wallet, history_label): + """Delete wallet-LOCAL will transactions saved under ``history_label``. + + ``save_valid_transactions_to_history`` stores the not-yet-signed + inheritance txs into the wallet's local history; those local + placeholders nominally spend the coins they reference. When the will is + REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the + stale placeholders must be removed so the coins become available again + to the new build (see ``Util.get_available_utxos``). Only + wallet-local/future (non-broadcast) txs whose label matches the history + label template are removed; confirmed/broadcast history is never + touched. Returns the txids that were removed. + """ + if not wallet or not getattr(wallet, "adb", None): + return [] + removed = [] + for txid, label in Will._wallet_labels(wallet): + if not label or not Util._label_matches_history(label, history_label): + continue + try: + height = int(wallet.adb.get_tx_height(txid).height()) + except Exception: + continue + if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE): + continue + try: + wallet.adb.remove_transaction(txid) + removed.append(txid) + except Exception as e: + _logger.error(f"remove from history failed for {txid}: {e}") + continue + try: + wallet.set_label(txid, None) + except Exception as e: + _logger.error(f"set_label failed for {txid}: {e}") + try: + wallet.save_db() + except Exception as e: + _logger.error(f"save_db failed after history purge: {e}") + return removed + @staticmethod def _add_transaction_to_history(wallet, tx, txid): """Store *tx* into the wallet's local history via ``adb``. @@ -1213,9 +1256,9 @@ class Will: if Util.parse_locktime_string(heirs[h][2]) >= check_date: count_heirs += 1 - if h not in heirs_found: - _logger.debug(f"heir: {h} not found") - raise HeirNotFoundException(h) + if h not in heirs_found: + _logger.debug(f"heir: {h} not found") + raise HeirNotFoundException(h) if not count_heirs: raise NoHeirsException("there are not valid heirs") if self_willexecutor and no_willexecutor == 0: diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index caafbea..84869f7 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -1135,13 +1135,14 @@ class BalBuildWillDialog(BalDialog): desired behaviour, and that the date shown in the panel/wizard must reflect this anticipated date (so the calendar .ics also uses it). - RELATIVE dates are additionally normalised here: a relative value - ("30d"/"1y") is re-parsed against "now" on every check, so it drifts - away from the fixed transaction locktime and the postpone check would - wrongly ask to invalidate the will every day. The stored locktime is - therefore frozen to the built transactions' absolute locktime, and a - relative threshold is frozen to its "N days before the delivery" - absolute value. + RELATIVE recipes ("30d"/"1y") are PRESERVED: they are resolved against + the built will's frozen locktime on every check (via + ``Util.resolve_locktime_against_tx`` for the postpone detection and + ``resolve_date_to_check(..., built_locktime=...)`` for the reference + timestamp), so they no longer drift away from the built transactions + and never trigger the daily invalidate prompt. Freezing them to an + absolute timestamp here would silently erase the user's relative + choice from WILL_SETTINGS. We route the update through BalWindow.update_setting_widgets, which is the single place that (1) stores the value in WILL_SETTINGS, (2) @@ -1156,72 +1157,46 @@ class BalBuildWillDialog(BalDialog): return min_locktime = int(min_locktime) stored_locktime = self.bal_window.will_settings["locktime"] - # A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed - # against "now" on every check, so it drifts one day per day away from - # the fixed tx locktime and the postpone check would ALWAYS see a - # postpone -> the plugin asks to invalidate the will every day. It must - # therefore be normalised here to the frozen absolute locktime of the - # built transactions, even when it happens to parse to the same moment - # today. (Only an absolute stored value is comparable, see below.) + # A RELATIVE stored value ("30d"/"1y") is PRESERVED: it is resolved + # against the built transactions on every check (the post-build + # `resolve_date_to_check` anchoring and `resolve_locktime_against_tx` + # in the postpone detection), so it no longer drifts and must not be + # frozen to an absolute timestamp here. Only an ABSOLUTE stored value + # is compared with the built transactions (see below). is_relative_locktime = ( isinstance(stored_locktime, str) and stored_locktime[-1:].lower() in ("d", "y") ) - # Current stored delivery date, as a comparable UNIX timestamp. - try: - current = int(Util.parse_locktime_string(stored_locktime)) - except Exception: - # If the stored value cannot be parsed, fall back to syncing. - current = None - # A genuine user-chosen POSTPONE (a later absolute date) is never - # overwritten; anything else is synced to the built transactions. - was_anticipation = current is not None and min_locktime < current - if not is_relative_locktime and current is not None and not was_anticipation: - pass - else: - _logger.debug( - f"sync delivery date to built tx locktime: " - f"{current} -> {min_locktime}" - ) - # Remember that we anticipated the date, so the later sign prompt can - # explain WHY signing is needed (see on_success_phase1). A pure - # relative->absolute normalisation is NOT an anticipation. - if was_anticipation: - self._date_was_anticipated = True - # update_setting_widgets stores the value, persists it and refreshes - # the date widgets in all panels/wizard (the .ics calendar too). - self.bal_window.update_setting_widgets( - min_locktime, "locktime", update_all=True - ) - # Same moving-target problem for a relative "Check Alive" threshold: - # it means "N days BEFORE the delivery" (the settings widget resolves it - # as real_threshold = locktime - N days), so it is normalised to that - # absolute date, referenced against the now-absolute stored locktime. - threshold_raw = self.bal_window.will_settings.get("threshold") - if ( - isinstance(threshold_raw, str) - and threshold_raw[-1:].lower() in ("d", "y") - ): + if not is_relative_locktime: + # Current stored delivery date, as a comparable UNIX timestamp. try: - locktime_ts = int( - Util.parse_locktime_string( - self.bal_window.will_settings["locktime"] - ) - ) - real_threshold = int( - BalTimestamp(threshold_raw) - .to_date(locktime_ts, reverse=True) - .timestamp() - ) - except Exception as e: - _logger.error(f"sync threshold to absolute failed: {e}") - else: + current = int(Util.parse_locktime_string(stored_locktime)) + except Exception: + # If the stored value cannot be parsed, fall back to syncing. + current = None + # A genuine user-chosen POSTPONE (a later absolute date) is never + # overwritten; a genuine automatic ANTICIPATION (built earlier + # than stored) is synced to the built transactions. + was_anticipation = current is not None and min_locktime < current + if was_anticipation: _logger.debug( - f"sync threshold {threshold_raw} -> absolute {real_threshold}" + f"sync delivery date to built tx locktime: " + f"{current} -> {min_locktime}" ) + # Remember that we anticipated the date, so the later sign + # prompt can explain WHY signing is needed + # (see on_success_phase1). + self._date_was_anticipated = True + # update_setting_widgets stores the value, persists it and + # refreshes the date widgets in all panels/wizard (the .ics + # calendar too). self.bal_window.update_setting_widgets( - real_threshold, "threshold", update_all=True + min_locktime, "locktime", update_all=True ) + # A relative "Check Alive" threshold ("N days BEFORE the delivery") is + # also PRESERVED: it is anchored on every check by + # ``resolve_date_to_check`` / ``resolve_guard_threshold``, so it does + # not need to be frozen to an absolute date here. def on_accept(self): try: diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 24736f3..55c3784 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -23,6 +23,7 @@ from ...core.checkalive import ( CheckAliveError, check_alive_expired, resolve_date_to_check, + resolve_guard_threshold, ) from .common import ( OP_RETURN_PREFIX, @@ -466,6 +467,31 @@ class BalWindow: def build_will(self, ignore_duplicate=True, keep_original=True): _logger.debug("building will...") + # Drop stale wallet-LOCAL will placeholders saved by previous prepares + # so their coins are available to this build (see remove_stale...). + Will.remove_stale_wallet_history( + self.window.wallet, self.bal_plugin.HISTORY_LABEL.get() + ) + # A (re)build may have anticipated the delivery (shorter heir recipes) + # while ``date_to_check`` is still anchored to the OLD built will. Using + # that stale anchor as the build filter would block every future + # delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will + # that is being built: its locktime is the earliest future delivery + # among the CURRENT heirs. The checks of the EXISTING will keep their + # anchored ``date_to_check`` (set in init_class_variables). + _new_locktime = min( + ( + Util.parse_locktime_string(h[2]) + for h in self.heirs.values() + ), + default=None, + ) + if _new_locktime: + self.date_to_check = resolve_date_to_check( + self.bal_plugin.is_basic_mode(), + self.will_settings, + built_locktime=_new_locktime, + ) will = {} # willtodelete = [] # willtoappend = {} @@ -745,6 +771,27 @@ class BalWindow: raise e + def is_locktime_below_threshold(self) -> bool: + """True when the stored settings make the delivery earlier than the + Check Alive threshold (the "locktime is lower than threshold" guard). + + Compares the delivery against the settings-derived threshold on the + SAME reference frame (see ``resolve_guard_threshold``), never against + the built-will-anchored ``date_to_check``: anchoring the guard to an + old, longer built will would wrongly fire right after the delivery was + shortened. The anchored reference still governs the validity and + expiry checks, which is where ``date_to_check`` belongs. + In BASIC mode there is no threshold, so the locktime is checked against + ``date_to_check`` (= now) exactly as before. + """ + locktime = Util.parse_locktime_string(self.will_settings["locktime"]) + threshold_ts = resolve_guard_threshold( + self.bal_plugin.is_basic_mode(), self.will_settings + ) + if threshold_ts is not None: + return locktime < threshold_ts + return self.date_to_check is not None and locktime < self.date_to_check + def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): try: _logger.info( @@ -757,6 +804,11 @@ class BalWindow: if not self.heirs: _logger.warning("not heirs {}".format(self.heirs)) return + # Free the coins locked by stale wallet-LOCAL will placeholders + # BEFORE the amount/UTXO checks below (Step 1) see them. + Will.remove_stale_wallet_history( + self.window.wallet, self.bal_plugin.HISTORY_LABEL.get() + ) try: self.init_class_variables() Will.check_amounts( @@ -791,8 +843,7 @@ class BalWindow: ) ) return - locktime = Util.parse_locktime_string(self.will_settings["locktime"]) - if locktime < self.date_to_check: + if self.is_locktime_below_threshold(): self.show_error(_("locktime is lower than threshold")) return if not self.no_willexecutor: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c046a55 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +"""Shared pytest fixtures. + +Guards every test against cross-file network pollution: several karen7 +regtest modules historically flipped ``electrum.constants.net`` to regtest at +import time, which broke unrelated offline tests (e.g. the CLI controller +suite) run in the same pytest process. +""" + +import pytest +from electrum import constants + + +@pytest.fixture(autouse=True) +def _restore_network(): + """Snapshot ``constants.net`` before each test and restore it after.""" + prev = constants.net + yield + constants.net = prev diff --git a/tests/test_cli_controller_offline.py b/tests/test_cli_controller_offline.py index 1960611..f793aa6 100644 --- a/tests/test_cli_controller_offline.py +++ b/tests/test_cli_controller_offline.py @@ -18,6 +18,7 @@ import shutil import sys import tempfile import time +import unittest.mock as mock sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) @@ -25,6 +26,8 @@ from electrum.simple_config import SimpleConfig from electrum.util import UserFacingException from bal.cli.controller import BalController +from bal.core.heirs import Heirs +from bal.core.util import Util VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf" @@ -227,6 +230,39 @@ def test_auto_rebuild_threshold_passed_invalidates(): assert result["invalidation_tx"] == {"txid": None, "tx": None} +def test_build_will_reanchors_date_to_check_to_new_locktime(): + """CLI mirror of the GUI regression: ``build_will`` must re-anchor + ``date_to_check`` to the CURRENT heirs' earliest delivery before building, + so an anticipated (shortened) rebuild is not blocked by the old built-will + anchor (which would yield NO_FUTURE_DATE in ``get_transactions``). + """ + with Plugin() as plugin: + plugin.USER_TYPE.set("advanced") + plugin.NO_WILLEXECUTOR.set(True) + plugin.ENABLE_MULTIVERSE.set(True) + plugin.WILL_SETTINGS.set({"threshold": "150d", "locktime": "2y", "baltx_fees": 20}) + c = _make_controller(plugin) + c.no_willexecutor = True + c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"] + + # Simulate an old built will frozen at 2y: reload keeps its (stale) + # anchor, which would reject the anticipated "1y" delivery. + c.init_class_variables() + stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400 + c.date_to_check = stale_anchor + assert Util.parse_locktime_string("1y") < c.date_to_check + + with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt: + result = c.build_will() + + assert result == {} + # build_will re-anchored date_to_check to the new 1y delivery... + expected = Util.parse_locktime_string("1y") - 150 * 86400 + assert abs(c.date_to_check - expected) < 3600 + # ...and used THAT anchor as the build filter, not the stale 2y one. + assert gt.call_args.args[-1] == c.date_to_check + + # ------------------------------------------------------------------ # # runner # ------------------------------------------------------------------ # diff --git a/tests/test_core_checkalive.py b/tests/test_core_checkalive.py index 644767c..2e73e50 100644 --- a/tests/test_core_checkalive.py +++ b/tests/test_core_checkalive.py @@ -18,6 +18,7 @@ from bal.core.checkalive import ( # noqa: E402 (path insert above) CheckAliveError, check_alive_expired, resolve_date_to_check, + resolve_guard_threshold, ) # ------------------------------------------------------------------ # @@ -148,6 +149,76 @@ def test_advanced_mode_relative_locktime_without_built_tx_falls_back(): assert abs(result - expected) < 1 +# ------------------------------------------------------------------ # +# resolve_guard_threshold +# ------------------------------------------------------------------ # + + +def test_guard_threshold_basic_mode_returns_none(): + fake_now = 1_800_000_000.0 + threshold = resolve_guard_threshold(True, {"threshold": "30d"}, now=fake_now) + assert threshold is None + + +def _guard_locktime(settings, fake_now): + """Reproduce the call-site locktime expression of the guard.""" + from bal.core.plugin_base import BalTimestamp + + return BalTimestamp(settings["locktime"]).to_timestamp(fake_now) + + +def test_guard_threshold_absolute(): + fake_now = 1_800_000_000.0 + locktime = fake_now + 90 * 86400 + threshold = locktime - 30 * 86400 + settings = {"locktime": locktime, "threshold": threshold} + assert resolve_guard_threshold(False, settings, now=fake_now) == threshold + + +def test_guard_threshold_relative_fresh_anchor(): + """A relative threshold must be anchored to the FRESH locktime so the + guard and the settings always share one reference frame. + + Regression for the false positive where a still-valid built will frozen at + a LONGER delivery ("2y") anchored ``date_to_check`` beyond the currently + stored shorter delivery ("1y"): the old guard compared the fresh "1y" + locktime against that anchored threshold and wrongly fired "locktime is + lower than threshold", even though the settings themselves are consistent + (locktime is 30d AFTER the threshold). + """ + fake_now = 1_800_000_000.0 + settings = {"locktime": "1y", "threshold": "30d"} + locktime = _guard_locktime(settings, fake_now) + threshold = resolve_guard_threshold(False, settings, now=fake_now) + assert threshold is not None + assert locktime > threshold # internally consistent: no fire + assert threshold > fake_now + # The helper takes no built anchor: a frozen "2y" built will must NOT + # contaminate the result, although resolve_date_to_check (the expiry + # reference) legitimately keeps using it. + frozen_two_years = locktime + 365 * 86400 + anchored = resolve_date_to_check( + False, settings, now=fake_now, built_locktime=frozen_two_years + ) + assert anchored > threshold # built anchor pushes date_to_check forward... + assert locktime < anchored # ...which is exactly what used to fire the bug + + +def test_guard_threshold_relative_locktime_absolute_threshold(): + fake_now = 1_800_000_000.0 + threshold = fake_now + 200 * 86400 + settings = {"locktime": "1y", "threshold": threshold} + assert resolve_guard_threshold(False, settings, now=fake_now) == threshold + # "1y" from now is later than the stored absolute threshold: allowed. + locktime = _guard_locktime(settings, fake_now) + assert locktime > threshold + + +def test_guard_threshold_missing_returns_none(): + fake_now = 1_800_000_000.0 + assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None + + # ------------------------------------------------------------------ # # check_alive_expired # ------------------------------------------------------------------ # diff --git a/tests/test_core_heirs.py b/tests/test_core_heirs.py index c292066..7fab3aa 100644 --- a/tests/test_core_heirs.py +++ b/tests/test_core_heirs.py @@ -36,6 +36,7 @@ from bal.core.heirs import ( is_op_return_address, validate_op_return_hex, ) +from bal.core.util import Util # ------------------------------------------------------------------ # # Constants @@ -167,6 +168,32 @@ def test_heirs_amount_to_float(): assert heirs.amount_to_float("notanumber") == 0.0 +def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs(): + """A relative heir must survive the amount filter when the build anchor + (``from_locktime``) is recalculated for the anticipated delivery. + + Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD + (longer) built will; an "1y" heir resolved before that anchor was excluded + by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the + anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the + "1y" heir is kept. + """ + wallet = FakeWallet() + heirs = Heirs(wallet) + heirs["carol"] = ["addr1", "100%", "1y"] + + # Stale anchor (old built 2y will still frozen): "1y" is in the past + # relative to it -> excluded from the amount calculation. + stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400 + _, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500) + assert "carol" not in percent_heirs + + # Recalculated anchor for the new (1y) delivery: the heir is retained. + new_anchor = Util.parse_locktime_string("1y") - 150 * 86400 + _, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500) + assert "carol" in percent_heirs + + # ------------------------------------------------------------------ # # Validation (static methods) # ------------------------------------------------------------------ # diff --git a/tests/test_core_will.py b/tests/test_core_will.py index 94a7249..4f5c28d 100644 --- a/tests/test_core_will.py +++ b/tests/test_core_will.py @@ -13,8 +13,14 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +from bal.core.checkalive import resolve_date_to_check from bal.core.util import copy_structure -from bal.core.will import Will, WillItem +from bal.core.will import ( + HeirNotFoundException, + NoHeirsException, + Will, + WillItem, +) # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) _VALID_TX_HEX = ( @@ -210,6 +216,59 @@ def test_check_heir_added_triggers_rebuild(): assert raised, "adding an heir must raise HeirNotFoundException" +def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs(): + """Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose + ADVANCED check window is anchored to the frozen built delivery must trigger + a plain rebuild (HeirNotFoundException), NOT "No Heirs". + + Earlier the count gate resolved each current relative recipe from *now* + while ``check_date`` was anchored to the (longer) frozen built locktime, so + every heir fell below the window and was silently excluded -> NoHeirs even + though the will simply needs rebuilding on the new, shorter schedule.""" + lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build) + will_heirs = {"alice": ["addr_alice", 5000, "2y"]} + current_heirs = {"alice": ["addr_alice", 5000, "1y"]} + will = _make_will_with_heirs(will_heirs, lt) + will["willid_1"].set_status("COMPLETE", True) + check_date = resolve_date_to_check( + False, {"locktime": "2y", "threshold": "150d"}, built_locktime=lt + ) + assert check_date < lt # the anchored window really precedes the delivery + raised = None + try: + Will.check_willexecutors_and_heirs( + will, copy_structure(current_heirs), {}, False, check_date, 100 + ) + except HeirNotFoundException: + raised = "rebuild" + except NoHeirsException: + raised = "noheirs" + assert raised == "rebuild", ( + f"shortened recipe on a signed will must rebuild, got {raised!r}" + ) + + +def test_all_heirs_past_check_date_still_noheirs(): + """The "no valid heirs" gate is preserved: when every heir is coherent with + the built will but its delivery lies before ``check_date``, the check still + reports NoHeirsException (there is literally nothing future to inherit).""" + lt = 1_900_000_000 + will_heirs = {"alice": ["addr_alice", 5000, str(lt)]} + will = _make_will_with_heirs(will_heirs, lt) + raised = None + try: + Will.check_willexecutors_and_heirs( + will, copy_structure(will_heirs), {}, False, lt + 86400, 100 + ) + except HeirNotFoundException: + raised = "rebuild" + except NoHeirsException: + raised = "noheirs" + assert raised == "noheirs", ( + f"a fully delivered will must report NoHeirs, got {raised!r}" + ) + + def test_needs_server_check(): """Check button selection logic: only a VALID, PUSHED will with a will-executor that is not yet CHECKED must be queried on the server. diff --git a/tests/test_core_will_extra.py b/tests/test_core_will_extra.py index 19f9234..eb3d1cd 100644 --- a/tests/test_core_will_extra.py +++ b/tests/test_core_will_extra.py @@ -450,6 +450,12 @@ class FakeADB: def remove_transaction(self, txid): self.removed.append(txid) + # Simulate the real adb: dropping a stored tx frees the outputs it spent. + for utxos in self.outputs.values(): + for utxo in utxos.values(): + if getattr(utxo, "spent_txid", None) == txid: + utxo.spent_txid = None + utxo.spent_height = None def get_spender(self, outpoint): txid = self.spenders.get(outpoint) @@ -837,6 +843,73 @@ def test_get_available_utxos_none_locktime_is_raw_view(): assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == [] +# ------------------------------------------------------------------ # +# Will.remove_stale_wallet_history (pre-build history purge) +# ------------------------------------------------------------------ # + +def test_remove_stale_wallet_history_frees_equal_locktime_spend(): + # The stale placeholders (saved by a previous prepare) have the SAME + # locktime as the will being rebuilt, so get_available_utxos does NOT + # restore their coins (see test_...does_not_restore_not_later_locktime). + # The pre-build purge deletes them and the coins become available again. + wallet, utxo = _wallet_with_local_spend(locktime=1000) + spender = "ab" * 32 + assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == [] + removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) + assert removed == [spender] + assert wallet.adb.removed == [spender] + assert spender not in wallet.labels + assert [ + u.prevout.to_str() + for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) + ] == [utxo.prevout.to_str()] + + +def test_remove_stale_wallet_history_keeps_confirmed_spender(): + # A broadcast (confirmed) BAL-labelled tx is never purged. + addr = "bcrt1qexample" + spender = "ab" * 32 + utxo = _make_utxo(spent_txid=spender, spent_height=100) + wallet = FakeWallet( + stored_txs={spender: _make_multisig_ptx(0, locktime=2000)}, + heights={spender: 100}, + outputs={addr: {utxo.prevout.to_str(): utxo}}, + addresses=[addr], + ) + wallet.labels[spender] = _HISTORY_LABEL + removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) + assert removed == [] + assert wallet.adb.removed == [] + assert wallet.labels[spender] == _HISTORY_LABEL + + +def test_remove_stale_wallet_history_keeps_unlabeled_local_spender(): + # Wallet-local BAL-status tx without a matching history label stays. + wallet, _ = _wallet_with_local_spend(locktime=1000) + spender = "ab" * 32 + wallet.labels[spender] = "some other label" + removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) + assert removed == [] + assert wallet.adb.removed == [] + assert wallet.labels[spender] == "some other label" + + +def test_remove_stale_wallet_history_noop_without_wallet_or_adb(): + assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == [] + wallet = FakeWallet() + wallet.adb = None + assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == [] + + +def test_remove_stale_wallet_history_never_raises(): + adb = MagicMock() + adb.get_tx_height.side_effect = RuntimeError("boom") + wallet = MagicMock() + wallet.adb = adb + wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL} + assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == [] + + # ------------------------------------------------------------------ # # Main # ------------------------------------------------------------------ # diff --git a/tests/test_gui_prepare_will_history.py b/tests/test_gui_prepare_will_history.py index 6bf527a..bdf5e77 100644 --- a/tests/test_gui_prepare_will_history.py +++ b/tests/test_gui_prepare_will_history.py @@ -179,6 +179,7 @@ def test_rebuild_path_schedules_full_refresh(): win.date_to_check = 1_800_000_000 win.will_settings = {"baltx_fees": 1, "locktime": "1 month"} win.bal_plugin = _CfgBag( + is_basic_mode=lambda: False, MAX_WILLEXECUTOR_FEE=_Cfg(1), SAVE_HISTORY=_Cfg(True), HISTORY_LABEL=_Cfg("LBL"), @@ -204,6 +205,46 @@ def test_rebuild_path_schedules_full_refresh(): schedule_mock.assert_called_once_with() +def test_rebuild_purges_stale_wallet_history_before_building(): + # The rebuild path must drop stale wallet-LOCAL will placeholders (saved by + # an earlier prepare) so their coins are available to the new build. + win = object.__new__(BalWindow) + win.disable_plugin = False + win.heirs = {"h": object()} + win.willexecutors = {} + win.no_willexecutor = True + win.willitems = {} + win.will = {} + win.date_to_check = 1_800_000_000 + win.will_settings = {"baltx_fees": 1, "locktime": "1 month"} + win.bal_plugin = _CfgBag( + is_basic_mode=lambda: False, + MAX_WILLEXECUTOR_FEE=_Cfg(1), + SAVE_HISTORY=_Cfg(True), + HISTORY_LABEL=_Cfg("LBL"), + ) + win.window = _FakeWindow() + win.window.wallet = _Wallet() + with ( + patch.object(Util, "get_available_utxos", return_value=[]), + patch.object(Util, "parse_locktime_string", return_value=1_800_000_001), + patch.object(Will, "get_min_locktime", return_value=0), + patch.object(Will, "check_amounts"), + patch.object(BalWindow, "init_class_variables"), + patch.object(BalWindow, "build_will"), + patch.object( + BalWindow, + "check_will", + side_effect=[NotCompleteWillException(), None], + ), + patch.object(BalWindow, "update_all"), + patch.object(BalWindow, "_schedule_history_refresh"), + patch.object(Will, "remove_stale_wallet_history") as purge_mock, + ): + BalWindow.build_inheritance_transaction(win) + purge_mock.assert_called_once_with(win.window.wallet, "LBL") + + # ------------------------------------------------------------------ # # Main # ------------------------------------------------------------------ # diff --git a/tests/test_gui_will_flows.py b/tests/test_gui_will_flows.py index 02031d6..7faafd7 100644 --- a/tests/test_gui_will_flows.py +++ b/tests/test_gui_will_flows.py @@ -390,6 +390,110 @@ def test_insufficient_funds_warns(): assert not ctl.willitems +def test_guard_not_blocked_by_old_built_will(): + """Regression: shortening the delivery in the STORED settings (relative + "1y"/"30d") while an old, still-VALID built will is frozen at a longer + locktime must NOT fire the "locktime is lower than threshold" guard. + + The old guard compared the fresh settings locktime against ``date_to_check`` + anchored to the built will (see ``resolve_date_to_check``), so a built-will + delivery longer than the settings' one made it fire even though the settings + are internally consistent (locktime is 30d AFTER the threshold). The guard + must instead compare the stored settings on a single reference frame + (``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its + built anchor for the expiry/validity checks. + """ + with _no_willexecutors(): + ctl = make_controller() + ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode + ctl.prepare_will() + txid, item = _single(ctl) + + # Freeze the built (VALID) will at a delivery one year longer than the + # now-shortened settings: the pre-fix guard would reject the rebuild. + item.tx.locktime = item.tx.locktime + 365 * 86400 + ctl.will_settings = {"locktime": "1y", "threshold": "30d"} + Util.fix_will_settings_tx_fees(ctl.will_settings) + + ctl.init_class_variables() + + # date_to_check is anchored to the built will (long delivery)... + assert ctl.date_to_check == item.tx.locktime - 30 * 86400 + # ...and the OLD guard would have fired here: + old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"]) + assert old_locktime < ctl.date_to_check + # but the settings themselves are consistent, so the guard must pass: + assert ctl.is_locktime_below_threshold() is False + assert not ctl.window.errors + + +def test_anticipated_rebuild_reanchors_date_to_check(): + """Regression (karen7): rebuilding a SIGNED will whose delivery was + anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must + succeed. + + ``date_to_check`` stays anchored to the OLD built delivery for the validity + checks, but ``build_will`` must re-anchor it to the NEW (earliest current) + delivery as its build filter: before the fix the stale 2028 anchor rejected + every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build + reported ``NO_FUTURE_DATE``. The old signed item is then superseded by + ``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will + is coherent again. + """ + with _no_willexecutors(): + ctl = make_controller() + ctl.bal_plugin.USER_TYPE.set("advanced") + # Per-heir deliveries require multiverse mode (the only way heirs can + # carry a different recipe than the settings locktime). + ctl.bal_plugin.ENABLE_MULTIVERSE.set(True) + ctl.will_settings = {"locktime": "2y", "threshold": "150d", "baltx_fees": 20} + Util.fix_will_settings_tx_fees(ctl.will_settings) + ctl.heirs["alice"][2] = "2y" + ctl.heirs["bob"][2] = "2y" + + # Build and sign a 2y will (the old, committed delivery). + ctl.prepare_will() + old_txid, _old_item = _single(ctl) + old_locktime = _old_item.tx.locktime + signed = ctl.sign_transactions(None) + _old_item.tx = Will.get_tx_from_any(str(signed[old_txid])) + Will.check_signatures(ctl.willitems, ctl.wallet) + assert _old_item.get_status("COMPLETE") + + # Anticipate: shorten every heir to 1y. + ctl.heirs["alice"][2] = "1y" + ctl.heirs["bob"][2] = "1y" + + ctl.init_class_variables() + # date_to_check stays anchored to the OLD built delivery... + assert ctl.date_to_check == old_locktime - 150 * 86400 + # ...and that stale anchor would reject the anticipated "1y" dates. + assert Util.parse_locktime_string("1y") < ctl.date_to_check + + # The rebuild must succeed (re-anchored to the new delivery). + willitems = ctl.build_inheritance_transaction() + + assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire" + new_valid = [ + it for tid, it in willitems.items() + if tid != old_txid and it.get_status("VALID") + ] + assert new_valid, "the anticipated (1y) will must build and stay VALID" + new_item = new_valid[0] + assert new_item.tx.locktime < old_locktime, "delivery must be anticipated" + # date_to_check was re-anchored to the rebuilt delivery (1y minus 150d). + assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600 + + # The old signed item is kept but superseded (REPLACED -> not VALID). + assert _old_item.get_status("REPLACED") is True + assert _old_item.get_status("VALID") is False + + # The rebuilt will is coherent (plain rebuild, no on-chain invalidation). + assert ctl.check_will() is True + assert not any("delivery date" in m for m in ctl.window.messages) + assert not ctl.window.errors + + def _run_all(): tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")] for fn in tests: diff --git a/tests/test_heir_relative_anchor.py b/tests/test_heir_relative_anchor.py index 4b36660..92d57b1 100644 --- a/tests/test_heir_relative_anchor.py +++ b/tests/test_heir_relative_anchor.py @@ -18,9 +18,10 @@ The two gates that produced the prompt are covered here: never read as EXPIRED because the check window drifts past the frozen tx locktime. -The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact -reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen -tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings +The reported state (reproduced hermetically here — the original live wallet +dump ``tests/karen7`` is gitignored and regenerated as the wallet evolves) is: +heirs with ``"1y"``, a signed/pushed/checked item whose frozen tx.locktime is +2027-08-05 (built 2026-08-05), and will_settings ``{"locktime": "2y", "threshold": "150d"}``. Run: @@ -28,16 +29,14 @@ Run: python3 tests/test_heir_relative_anchor.py """ -import json import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) +import pytest # noqa: E402 (path insert above) from electrum import constants # noqa: E402 (path insert above) -constants.net = constants.BitcoinRegtest - from bal.core.checkalive import resolve_date_to_check # noqa: E402 from bal.core.util import copy_structure # noqa: E402 from bal.core.will import ( # noqa: E402 @@ -49,6 +48,16 @@ from bal.core.will import ( # noqa: E402 WillPostponedException, ) + +@pytest.fixture(autouse=True) +def _regtest_net(): + """Run these regtest-focused tests with BitcoinRegtest, restoring mainnet + afterwards so sibling test modules are unaffected by the net switch.""" + constants.net = constants.BitcoinRegtest + yield + constants.net = constants.BitcoinMainnet + + # A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0; # the tests override ``tx.locktime`` to simulate the frozen signed locktime. _VALID_TX_HEX = ( @@ -158,55 +167,52 @@ def test_absolute_postpone_on_signed_still_detected(): # ------------------------------------------------------------------ # -# karen7 wallet regression (real fixture) +# karen7 regression (hermetic, no live wallet fixture) # ------------------------------------------------------------------ # - -def _load_karen7(): - path = os.path.join(os.path.dirname(__file__), "karen7") - with open(path) as f: - return json.load(f) +# karen7's reported state, reproduced hermetically: heirs "1y", a signed item +# frozen at delivery 2027-08-05 (built 2026-08-05), will_settings with a +# relative "150d" delivery window and a "2y" promised locktime. +_WILL_SETTINGS = {"locktime": "2y", "threshold": "150d"} def test_karen7_frozen_delivery_not_expired(): """ADVANCED date_to_check anchored to the frozen tx locktime: the check window opens BEFORE the delivery, so the will is never read as expired.""" - data = _load_karen7() - will_settings = data["will_settings"] - valid_wid = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" - wi = WillItem(data["will"][valid_wid], _id=valid_wid) - built_locktime = Will.get_min_locktime({valid_wid: wi}) + heirs = {"alice": ["addr_alice", 5000, "1y"]} + item = _make_will_item(copy_structure(heirs), _FROZEN, status_complete=True) + will = {"willid_1": item} + built_locktime = Will.get_min_locktime(will) assert built_locktime is not None - assert built_locktime == int(wi.tx.locktime) + assert built_locktime == int(item.tx.locktime) date_to_check = resolve_date_to_check( - False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime + False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=built_locktime ) 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, + False, _WILL_SETTINGS, now=1_800_000_000.0 + 10 * 86400, built_locktime=built_locktime, ) assert date_to_check == later def test_karen7_unchanged_heirs_are_coherent(): - """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 = "28b64bfd83878d15c668473aa695a2b9bc23196bc61ab3149e8f33241826978d" + """Unchanged relative "1y" heirs are coherent with the frozen signed tx: + the plugin must NOT ask to invalidate the will.""" + heirs = {"alice": ["addr_alice", 5000, "1y"]} # 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"], + False, _WILL_SETTINGS, now=1_800_000_000.0, built_locktime=frozen_locktime, ) outcome = _run_heir_check( - data["will"][valid_wid]["heirs"], - data["heirs"], + copy_structure(heirs), + copy_structure(heirs), frozen_locktime, status_complete=True, ) @@ -219,6 +225,7 @@ def test_karen7_unchanged_heirs_are_coherent(): # ------------------------------------------------------------------ # if __name__ == "__main__": + constants.net = constants.BitcoinRegtest for name in sorted(dir()): if name.startswith("test_"): globals()[name]() diff --git a/tests/test_sync_locktime_built_txs.py b/tests/test_sync_locktime_built_txs.py index f969f91..aa62204 100644 --- a/tests/test_sync_locktime_built_txs.py +++ b/tests/test_sync_locktime_built_txs.py @@ -2,13 +2,17 @@ Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``. This is the post-build sync that keeps the plugin's stored delivery date -(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the -BUILT transactions' fixed locktime. The bug it fixes (reported by the owner): +(WILL_SETTINGS["locktime"]) in lockstep with the BUILT transactions' fixed +locktime when the core AUTOMATICALLY anticipates it (one day earlier than +stored). - ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin - asks to invalidate the will EVERY DAY. The relative value is re-parsed - against "now" on every check, so it drifts one day per day away from the - fixed tx locktime and the postpone check always sees a "postpone". +RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem +that once forced freezing them to absolute timestamps is solved at the root by +anchoring every relative recipe against the built transactions +(``Util.resolve_locktime_against_tx`` for the postpone detection, +``resolve_date_to_check(..., built_locktime=...)`` for the reference +timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date +moves the stored value. The method is exercised with a lightweight fake ``self`` (no Qt event loop, no Electrum wallet) by calling it as an unbound method. @@ -24,7 +28,6 @@ from types import SimpleNamespace sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) -from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above) from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above) # ------------------------------------------------------------------ # @@ -68,34 +71,30 @@ def _call_sync(will_settings, tx_locktimes, recorded): # Tests # ------------------------------------------------------------------ # -def test_relative_locktime_normalized_to_absolute(): - """The reported bug: a relative stored locktime is frozen to the absolute - value of the built transaction, even when it parses to the same moment.""" +def test_relative_locktime_preserved(): + """A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild: + it is anchored against the built transactions on every check, so it must + not be frozen to an absolute timestamp in WILL_SETTINGS.""" tx_locktime = 1_800_000_000 recorded = [] fake = _call_sync( {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded ) - assert fake.bal_window.will_settings["locktime"] == tx_locktime - assert fake.bal_window.will_settings["locktime"] != "90d" - # A pure relative->absolute normalisation is NOT an anticipation: the sign - # prompt must not claim the date was anticipated. + assert fake.bal_window.will_settings["locktime"] == "90d" + assert fake.bal_window.will_settings["threshold"] == "30d" + assert recorded == [], "a relative recipe must never be rewritten" assert fake._date_was_anticipated is False -def test_relative_threshold_frozen_to_absolute(): - """A relative threshold ("N days BEFORE the delivery") is normalised to the - same absolute value the settings widget computes (real_threshold).""" +def test_relative_threshold_preserved(): + """Same for the relative "Check Alive" threshold: it stays relative.""" tx_locktime = 1_800_000_000 recorded = [] fake = _call_sync( {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded ) - expected = int( - BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp() - ) - assert fake.bal_window.will_settings["threshold"] == expected - assert ("threshold", expected, True) in recorded + assert fake.bal_window.will_settings["threshold"] == "30d" + assert recorded == [] def test_absolute_locktime_unchanged_on_equal(): @@ -113,8 +112,8 @@ def test_absolute_locktime_unchanged_on_equal(): def test_anticipation_sets_flag_and_moves_earlier(): - """A real anticipation (built locktime earlier than the stored absolute - one) still moves the date earlier and flags the sign prompt.""" + """A real automatic anticipation of an ABSOLUTE stored date (built earlier + than stored) still moves the date earlier and flags the sign prompt.""" tx_locktime = 1_700_000_000 recorded = [] fake = _call_sync( @@ -129,7 +128,7 @@ def test_anticipation_sets_flag_and_moves_earlier(): def test_stored_earlier_than_built_never_moved_later(): """A stored absolute date that is already EARLIER than the built txs (the user moved the delivery later) is never pulled back up on rebuild: only - anticipation (built < stored) and relative normalisation move the value.""" + anticipation (built < stored) moves the value.""" stored = 1_800_000_000 recorded = [] fake = _call_sync( @@ -142,39 +141,39 @@ def test_stored_earlier_than_built_never_moved_later(): def test_multiple_txs_uses_minimum_locktime(): - """When several transactions carry different locktimes, the minimum is used - (owner-confirmed behaviour for the delivery date shown in the UI).""" + """When several ABSOLUTE transactions carry different locktimes, the minimum + is used for a genuine automatic anticipation (owner-confirmed behaviour for + the delivery date shown in the UI).""" min_locktime = 1_750_000_000 recorded = [] fake = _call_sync( - {"locktime": "90d", "threshold": "30d"}, + {"locktime": 1_800_000_000, "threshold": 1_600_000_000}, [min_locktime, min_locktime + 86_400], recorded, ) assert fake.bal_window.will_settings["locktime"] == min_locktime -def test_relative_locktime_stops_daily_postpone(): - """End-to-end guard for the reported bug: after the sync, re-parsing the - stored (now absolute) locktime on later days always equals the built - tx locktime, so the postpone check never fires again.""" - from datetime import datetime, timedelta +def test_relative_locktime_stays_coherent_via_anchor(): + """Daily-drift guard: an UNCHANGED relative recipe is resolved against the + tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT + being frozen to an absolute value it still reads as COHERENT (== tx + locktime) on later days - the postpone check never fires again.""" + from datetime import datetime, timedelta, timezone from bal.core.util import Util - tx_locktime = 1_800_000_000 - recorded = [] - fake = _call_sync( - {"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded - ) - stored = fake.bal_window.will_settings["locktime"] + # resolve_locktime_against_tx normalises to UTC midnight before anchoring, + # so use a midnight-UTC frozen tx locktime (the timestamp the engine itself + # stores after building). + tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp()) + built = "90d" # recipe frozen at build time + current = "90d" # unchanged recipe today for _day in range(0, 7): - # Simulate the check on later days: parse the STORED value (which is - # now the absolute tx locktime) and compare with the fixed tx locktime. - new_locktime = Util.parse_locktime_string(stored) - assert new_locktime == tx_locktime - assert new_locktime <= tx_locktime # no POSTPONE / drift - # Sanity: a RELATIVE value would have drifted past it (the bug). + resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime) + assert resolved == tx_locktime # no POSTPONE / drift + # Sanity: a naive forward-from-now re-parse would have drifted past it + # (the bug the anchor fixes). drifted = int( ( datetime.fromtimestamp(tx_locktime) + timedelta(days=1)