core+gui+cli: anticipation/rebuild dates, preserve relative settings
Fixes around the delivery/build/check date handling (karen7 regtest):
- build_will (GUI + CLI) re-anchors date_to_check to the CURRENT heirs'
earliest future delivery before building, so an anticipated rebuild is no
longer blocked by the stale old-will anchor (NO_FUTURE_DATE). The checks of
the existing will keep their anchored date_to_check.
- _sync_locktime_to_built_txs now PRESERVES RELATIVE locktime/threshold
recipes ("2y"/"150d") in WILL_SETTINGS instead of freezing them to absolute
timestamps: the anchored comparisons (resolve_locktime_against_tx and
resolve_date_to_check with built_locktime) already prevent the daily
invalidate prompt. Absolute values still sync on a genuine automatic
anticipation.
- Will.remove_stale_wallet_history drops stale wallet-LOCAL will placeholders
before every (re)build in GUI and CLI so their coins are available again.
- check_willexecutors_and_heirs raises HeirNotFoundException outside the
count_heirs gate: a shortened relative recipe on a signed will now triggers
a plain rebuild ("no heirs" only when there really are none).
- is_locktime_below_threshold compares the settings on one reference frame
(resolve_guard_threshold), no longer against the built-will anchor.
Tests: purge unit + call-site, no-heirs, guard, anticipated-rebuild
end-to-end (GUI) + CLI mirror + fixed_percent_lists_amount unit,
relative-preserve sync; conftest restores electrum.constants.net after each
test (cross-file pollution guard).
This commit is contained in:
379
QML_PLAN.md
379
QML_PLAN.md
@@ -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 `<plugin>/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 `<plugins>/<name>/qml/<loader>.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).
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user