From dc166d04ff91080e60dd12b56d6bf5aa28e5d1ce Mon Sep 17 00:00:00 2001 From: donkey-ai Date: Sun, 28 Jun 2026 23:00:23 -0400 Subject: [PATCH] feat(bal): Group A (timestamps + statuses + anticipate docs) and Group B (auto-sign) v0.3.4 Bump plugin version to 0.3.4 (manifest, __init__, plugin_base, VERSION). GROUP A - A1: remove block-height locktimes; the plugin now uses UNIX timestamps only. The NLOCKTIME_BLOCKHEIGHT_MAX guard is kept on purpose (it forces every locktime to be a timestamp). chk_locktime is now 2-arg; int_locktime and anticipate_locktime no longer accept blocks; RAW input only accepts d/y. Two now-dormant configs (LOCKTIME_BLOCKS, LOCKTIMEDELTA_BLOCKS) are kept with comments to avoid touching persisted keys. - A2: rename PENDING -> MEMPOOL everywhere (label 'Mempool', yellow #ffce30); add new UPDATED status; ANTICIPATED & UPDATED keep VALID; documented set_status rules; backward-compat migration (old PENDING -> MEMPOOL). - A3: clarify that anticipating to a future date only rebuilds (never invalidates), while only a past locktime invalidates (WillExpired). Code was already correct; only the comment and docs were fixed. Colour follow-up: UPDATED lightened from #800080 to #b266b2 (more readable), updated in theme.py, docs and the theme test. GROUP B - B1: verified the 'Create your will' button already opens the guided wizard (no code change needed). - B2: new persisted AUTO_SIGN setting (default ON) with an 'Auto-sign on Check' checkbox in the settings dialog. When enabled, Check signs and broadcasts automatically; the wallet password is requested only for encrypted wallets. B2 follow-up (fixes reported after testing): - Remove the duplicate sign/broadcast cycle in lists.check(); build_will_task() already signs and broadcasts. - Suppress the manual 'press Sign/Broadcast' hint and its popup when AUTO_SIGN is ON (kept when OFF). - Make broadcast one-shot: removed the retry flag and the Exception('retry'); failed will-executors stay PUSH_FAIL and are skipped (no endless retry). PUSHED transactions are already excluded from re-collection. Docs: inheritance-options.md/.html and inheritance-flow.svg updated to v0.3.4. Tests: 206 passing (new test_anticipate_manual_locktime, test_anticipate_past_locktime, test_group_b_auto_sign; updated core/util, core/will_extra, gui/theme, gui/widgets). CHANGELOG.md added with one numbered entry per task. --- CHANGELOG.md | 380 +++++++++++++++++++++++ bal/VERSION | 2 +- bal/__init__.py | 2 +- bal/core/plugin_base.py | 19 +- bal/core/util.py | 92 +++--- bal/core/will.py | 163 +++++++--- bal/gui/qt/dialogs.py | 42 ++- bal/gui/qt/lists.py | 6 + bal/gui/qt/plugin.py | 27 +- bal/gui/qt/theme.py | 3 +- bal/gui/qt/widgets.py | 23 +- bal/gui/qt/window.py | 35 ++- bal/manifest.json | 2 +- docs/images/inheritance-flow.svg | 32 +- docs/inheritance-options.html | 71 +++-- docs/inheritance-options.md | 89 ++++-- tests/test_anticipate_manual_locktime.py | 180 +++++++++++ tests/test_anticipate_past_locktime.py | 121 ++++++++ tests/test_core_util.py | 39 +-- tests/test_core_will_extra.py | 72 ++++- tests/test_group_b_auto_sign.py | 155 +++++++++ tests/test_gui_theme.py | 15 +- tests/test_gui_widgets.py | 9 +- 23 files changed, 1374 insertions(+), 205 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/test_anticipate_manual_locktime.py create mode 100644 tests/test_anticipate_past_locktime.py create mode 100644 tests/test_group_b_auto_sign.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2d9a87b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,380 @@ +# CHANGELOG + +This file records the work done on the BAL (Bitcoin After Life) Electrum +inheritance plugin, one numbered entry per task. + +Each entry lists: task title, date, files changed, and outcome +(DONE / UNRESOLVED). It is meant to make it easy to review what was done and, +if needed, to roll back to a previous state. + +--- + + + +## 1. A1 - Remove block-height locktimes (timestamps only) + +**Date:** 2026-06-22 + +**Goal (OPUS plan, Group A / A1):** Remove block-height locktimes from the +codebase so that ALL ordering and comparison use UNIX timestamps only. The +`NLOCKTIME_BLOCKHEIGHT_MAX` guard is intentionally KEPT, because it forces every +locktime to be a timestamp (it is a safety "bouncer", not block-height ordering). + +**What changed:** + +- `bal/core/util.py` + - `str_to_locktime`: removed the `"b"` (block) suffix; only `"d"` (days) and + `"y"` (years) relative suffixes are accepted now. + - `parse_locktime_string`: removed the block-height (`"b"`) branch. The + `w` (wallet) argument is kept only for call-site compatibility (now unused). + - `int_locktime`: removed the `blocks` argument (and the `blocks * 600` + seconds-per-block conversion). New signature: + `int_locktime(seconds=0, minutes=0, hours=0, days=0)`. + - `chk_locktime`: signature changed from + `(timestamp_to_check, block_height_to_check, locktime)` to + `(timestamp_to_check, locktime)`; comparison is now purely timestamp-based. + - `anticipate_locktime`: removed the `blocks` argument and the block-height + branch; it now only moves a timestamp earlier (by hours/days). The Windows + overflow clamp and the `out < 1` clamp are kept. + - Expanded the `LOCKTIME_THRESHOLD` comment to explain the timestamp-only model. + +- `bal/core/will.py` + - Removed the `from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX` import + (it was only used by the dead block-height branch). + - `check_will`: removed the `block_to_check` parameter. + - `is_will_valid`: removed the `block_to_check` parameter. + - `check_will_expired`: removed the `block_to_check` parameter and the + `if locktime <= NLOCKTIME_BLOCKHEIGHT_MAX:` block-height branch; expiry is + now decided purely by comparing the locktime against `timestamp_to_check`. + - Added/expanded docstrings on the three methods above. + - KEPT `utxo.block_height` in the coinbase-maturity check (line ~399): that is + a legitimate Electrum UTXO attribute, NOT a BAL locktime concept. + +- `bal/gui/qt/window.py` + - `check_will`: stopped passing the removed `block_to_check` argument. + - `init_class_variables`: removed the dead `locktime_blocks`, `current_block` + and `block_to_check = 0` assignments (replaced by an explanatory comment). + +- `bal/gui/qt/widgets.py` + - `LockTimeRawEdit`: removed the `"b"` (block) suffix handling from + `replace_str`, `numbify` and the `isblocks` flag; only `"d"` and `"y"` remain. + - Added a clarifying comment on the kept guard + `LockTimeDateEdit.min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1`. + +- Tests updated for the new signatures / behaviour: + - `tests/test_core_util.py`: `test_str_to_locktime` (now expects `"144b"` to be + rejected), `test_int_locktime` (no `blocks`), `test_chk_locktime` (2-arg), + `test_anticipate_locktime` (no `blocks`). Added `import pytest`. + - `tests/test_core_will_extra.py`: `test_check_will` now calls the 4-arg + `check_will`. + - `tests/test_anticipate_past_locktime.py`: `chk_locktime` call updated to 2-arg. + - `tests/test_gui_widgets.py`: `test_locktime_raw_edit_replace_str` now expects + `"b"` to be left untouched. + +**Verification:** +- `ruff check` on all modified production files: no new errors introduced + (`bal/core/util.py` is clean; pre-existing baseline warnings unchanged). +- Full test suite: `190 passed` + (`tests/test_core_*.py tests/test_gui_*.py tests/test_anticipate_past_locktime.py`). + +**Dormant block-based config kept on purpose (owner decision: keep, comment why):** +- `bal/core/plugin_base.py` still defines two block-based stored configs that + are no longer read anywhere after A1: + - `LOCKTIME_BLOCKS` (`"bal_locktime_blocks"`) + - `LOCKTIMEDELTA_BLOCKS` (`"bal_locktimedelta_blocks"`) + Per the owner's decision they are KEPT in place (dormant) to avoid touching + persisted config keys that may already exist in some users' saved settings. + An explanatory comment was added above each line stating they are unused now + and why they are intentionally retained. + +**Outcome:** DONE. + +--- + +## 2. A2 - Status definitions, colours and VALID rules (MEMPOOL rename + UPDATED) + +**Date:** 2026-06-22 + +**Goal (OPUS plan, Group A / A2):** Define the inheritance statuses, their +colours and their VALID rules. The fine moment-by-moment assignment of +ANTICIPATED / UPDATED will be validated by the Group E tests; A2 sets up the +state machine and colours. + +**Status meanings (for reference):** +- **ANTICIPATED** - locktime anticipated by 1 day vs a pre-existing tx with the + same heirs; the tx STAYS VALID. +- **REPLACED** - an input is spent by a new tx with a LOWER locktime; loses + VALID, cascades to children. +- **INVALIDATED** - an input is spent by a mempool/confirmed tx and the previous + tx is no longer in the will; loses VALID. +- **UPDATED** (new) - the tx was spendable AND valid, and a new tx replaces it + keeping the SAME locktime and SAME heirs; STAYS VALID. +- **MEMPOOL** (renamed from PENDING) - the tx has been seen in the Electrum + mempool; loses VALID. +- **CONFIRMED** - the tx is confirmed in the blockchain; loses VALID. + +**What changed:** + +- `bal/core/will.py` + - Renamed the status key `PENDING` -> `MEMPOOL` everywhere + (`STATUS_DEFAULT`, `set_status`, and the three `get_status(...)` reads in + `check_invalidated` / `search_rai`). Visible label: "Mempool". + - Added the new status `UPDATED` to `STATUS_DEFAULT` (label "Updated"). + - `set_status`: VALID rules now documented and updated: + - `INVALIDATED`, `REPLACED`, `CONFIRMED`, `MEMPOOL` -> clear VALID. + - `ANTICIPATED` and `UPDATED` -> KEEP VALID (intentionally NOT in the + clear-VALID list). + - `CONFIRMED`, `MEMPOOL` -> clear INVALIDATED (unchanged behaviour). + - Added a full docstring to `set_status` explaining all side effects. + - **Backward-compatibility migration (owner decision "Modo B"):** in + `__init__`, a will saved by an older plugin version that stores the legacy + `PENDING` flag is migrated to `MEMPOOL`, so no state is lost on load. The new + `MEMPOOL` key wins if both are present. + +- `bal/gui/qt/theme.py` + - Renamed the `PENDING` colour entry to `MEMPOOL` (#ffce30 yellow, unchanged). + - Added `UPDATED` -> #800080 (violet) in the priority list, placed after + REPLACED and before CONFIRMED/MEMPOOL. + +- Tests: + - `tests/test_gui_theme.py`: renamed the pending colour test to + `test_color_mempool`, updated the "overrides lower" test, and added + `test_color_updated` (#800080). + - `tests/test_core_will_extra.py`: renamed `test_check_invalidated_pending` + -> `test_check_invalidated_mempool`; updated `test_check_will`. Added new + tests: `test_legacy_pending_migrates_to_mempool`, + `test_new_mempool_wins_over_legacy_pending`, `test_updated_status_keeps_valid`, + `test_anticipated_status_keeps_valid`, `test_mempool_status_clears_valid`. + +**Verification:** +- `ruff check` on modified production files: no new errors introduced. +- Full test suite: `196 passed` + (`tests/test_core_*.py tests/test_gui_*.py tests/test_anticipate_past_locktime.py`). + +**Note:** the exact moment ANTICIPATED / UPDATED get assigned during real flows +is validated by the Group E tests (per the owner's decision). + +**Outcome:** DONE. + +--- + +## 3. A3 - "Move date earlier (anticipate)" must not invalidate + +**Date:** 2026-06-22 + +**Goal (OPUS plan, Group A / A3):** (a) explain the inheritance-options table; +(b) make "Move date earlier (anticipate)" only anticipate (rebuild), NOT +invalidate; (c) regenerate a clearer English table. + +**Owner decisions captured during DISCOVER:** +- D1 = the case to handle is the user MANUALLY setting a smaller locktime in the + wizard (e.g. from "90 days" to "30 days"). +- D2 = case A1 (smaller locktime, still in the future): plain REBUILD with the + new locktime, NEVER invalidate, even if the tx was already signed/sent. +- D3 = the genuine-expiry case (new date in the past) keeps invalidating; only + the documentation is wrong and must be fixed. +- Chosen path = X (fix BOTH code clarity and documentation). + +**Analysis result (verified with tests, not assumed):** +The core logic was ALREADY correct for D2. A diagnostic confirmed: +- Case A1 (smaller, future locktime, signed OR unsigned) -> + `HeirNotFoundException` (a rebuild signal), NEVER `WillExpiredException`. So no + on-chain invalidation happens. This already matches the owner's decision. +- Case A2 (locktime in the past) -> `WillExpiredException` -> invalidation. This + is a genuine expiry and is intentionally kept. + +So the real defect was in the DOCUMENTATION (the table wrongly claimed +"anticipate -> always invalidate"). No behavioural code change was needed. + +**What changed:** + +- `bal/core/will.py` + - Added a clarifying comment in `check_willexecutors_and_heirs` explaining that + anticipating to a FUTURE date is a plain rebuild that NEVER invalidates (even + when signed/sent), while only a date in the PAST is a genuine expiry handled + by `check_will_expired`. No logic change. + +- `tests/test_anticipate_manual_locktime.py` (new) + - Permanent regression tests guaranteeing: + `test_a1_anticipate_unsigned_triggers_rebuild_not_invalidate`, + `test_a1_anticipate_signed_triggers_rebuild_not_invalidate` (A1 = rebuild, no + invalidate, signed or not), and + `test_a2_past_locktime_is_genuinely_expired` (A2 = WillExpired kept). + +- `docs/inheritance-options.md` + - Split the "Move date EARLIER" row into two: "still in the future" (rebuild, + no fee, signed or not) vs "into the past" (invalidate, WillExpired). + - Rewrote the "why anticipate" notes (anticipate is safe, opposite of postpone). + - Fixed the quick-reference summary table and the Golden Rules accordingly. + - Updated the status section to match A2 (MEMPOOL instead of PENDING; added + ANTICIPATED/UPDATED keep-VALID rows) and rewrote the colour table in the exact + priority order used by `gui/qt/theme.py` (incl. UPDATED #800080 violet). + - Updated the Mermaid decision flow to the corrected branching. + +- `docs/inheritance-options.html` + - Mirrored all the above .md changes (status table, colour table with new pill + colours, section 4.1 table + notes, summary table, Golden Rules, Mermaid). + - Bumped the document version footer to v0.3.4. + +- `docs/images/inheritance-flow.svg` + - Reworked the "anticipate" branch: the decision is now "new date in the PAST?" + -> invalidate (WillExpired); otherwise "moved earlier? (anticipate)" -> + rebuild only, NEVER invalidates. Bumped subtitle to v0.3.4. Verified the SVG + is still well-formed XML. + +**Verification:** +- `ruff check` on changed Python files: no new errors introduced. +- Full test suite: `199 passed` + (`tests/test_core_*.py tests/test_gui_*.py tests/test_anticipate_past_locktime.py + tests/test_anticipate_manual_locktime.py`). + +**Outcome:** DONE. + +--- + +## 4. A2 follow-up - UPDATED status colour lightened + +**Date:** 2026-06-22 + +**Goal:** After testing the v0.3.4 build, the original UPDATED colour +(`#800080`, dark violet) was reported as too dark to read in the list. +Lighten it to a more readable light violet while keeping it distinct from +MEMPOOL (yellow `#ffce30`). + +**What changed:** + +- `bal/gui/qt/theme.py` + - UPDATED colour changed from `#800080` (dark violet) to `#b266b2` + (light violet) in `_STATUS_COLOR_PRIORITY`. Priority position unchanged + (after REPLACED, before CONFIRMED/MEMPOOL). + +- `docs/inheritance-options.md` / `docs/inheritance-options.html` + - Updated the colour table and the `.pill.violet` CSS to `#b266b2` + ("light violet"). + +- `tests/test_gui_theme.py` + - `test_color_updated` now asserts `#b266b2`. + +**Verification:** +- Full test suite: see run below. + +**Outcome:** DONE. + +--- + +## 5. B1 + B2 - Guided wizard verification and Auto-sign on Check + +**Date:** 2026-06-22 + +**Goal (OPUS plan, Group B):** +- **B1:** the "Create your will" button should open the step-by-step guided + wizard. +- **B2:** the "Check" action should be able to automatically sign and broadcast + the will, controlled by an "Auto-sign" checkbox in the settings dialog, + default ON. + +**B1 - finding (no code change needed):** +- The "Create your will" toolbar button is already wired to + `BalWindow.init_wizard`, which opens `BalWizardDialog` - the step-by-step + wizard (Heirs -> Locktime & Fee -> Will-Executor download -> Will-Executor -> + build will). This already matches the requested behaviour, so no code change + was required for B1. + +**B2 - what changed:** + +- `bal/core/plugin_base.py` + - Added a new persisted setting `AUTO_SIGN` + (`BalConfig(config, "bal_auto_sign", True)`), default ON, with an + explanatory comment. + +- `bal/gui/qt/plugin.py` (`settings_dialog`) + - Added an "Auto-sign on Check" checkbox bound to `AUTO_SIGN`, with a tooltip + explaining that the wallet password is requested only if the wallet is + encrypted. Re-numbered the grid rows so the new checkbox (row 3) does not + overlap the existing widgets; also fixed the "Event sescription" -> + "Event description" label typo. + +- `bal/gui/qt/window.py` + - Added `auto_sign_and_broadcast()`: signs the will and, only after signing + succeeds, broadcasts it to the will-executors. It reuses + `ask_password_and_sign_transactions(callback=...)`; the existing + `get_wallet_password()` already prompts only for encrypted wallets, so a + password-less wallet is handled with no prompt. + +- `bal/gui/qt/lists.py` (`check`) + - After the server check, when `AUTO_SIGN` is enabled, calls + `auto_sign_and_broadcast()`. When the setting is OFF the behaviour is + unchanged (check only). + +- `tests/test_group_b_auto_sign.py` (new) + - 5 tests: AUTO_SIGN defaults ON and can be disabled; sign happens before + broadcast; encrypted wallet prompts for the password; un-encrypted wallet + signs and broadcasts with no prompt. + +**Verification:** +- `ruff check` on changed Python files: no new errors introduced + (new test file is ruff-clean). +- Full test suite: `204 passed`. + +**Outcome:** DONE. + +--- + +## 6. B2 follow-up - Remove duplicate broadcast and make broadcast one-shot + +**Date:** 2026-06-22 + +**Problems reported after testing the v0.3.4 Group B build:** +1. The "Building Will" dialog still showed "Next step (manual): press + 'Broadcast'..." even though the will had already been broadcast. +2. A second, duplicate popup ("Informazioni") repeated the same manual hint. +3. When several transactions were broadcast to different will-executors and one + server failed, the plugin tried to retry the broadcast, which could loop + forever against a server that never answers. + +**Root cause:** +- `lists.py check()` first runs `BalBuildWillDialog.build_will_task()` (which + already checks, signs and broadcasts), and then a second + `auto_sign_and_broadcast()` was triggered - a duplicate sign/broadcast cycle. +- The "Building Will" dialog always printed the manual "press Sign/Broadcast" + hint and a follow-up popup, which is wrong when broadcasting is automatic. +- `loop_push()` used a `retry` flag and raised `Exception("retry")` whenever any + will-executor failed. + +**What changed (Fix A - no duplicate broadcast / no wrong manual hint):** + +- `bal/gui/qt/lists.py` + - `check()` no longer calls `auto_sign_and_broadcast()`. Signing and + broadcasting are already done by `build_will_task()`; the duplicate cycle + was removed (replaced by an explanatory comment). + +- `bal/gui/qt/window.py` + - Removed the now-unused `auto_sign_and_broadcast()` method. + +- `bal/gui/qt/dialogs.py` + - `_show_next_steps_hint()` returns early (no in-dialog hint, no popup) when + `AUTO_SIGN` is ON, because the will has already been signed and broadcast. + When `AUTO_SIGN` is OFF the previous manual hints are kept. + +**What changed (Fix B - one-shot broadcast, no endless retry):** + +- `bal/gui/qt/dialogs.py` (`loop_push`) + - Removed the `retry` flag, the `retry_flag["value"] = True` assignments and + the final `if retry: raise Exception("retry")`. The broadcast now contacts + each selected will-executor ONCE: successful transactions become PUSHED, + failed/timed-out ones are left as PUSH_FAIL and simply skipped (no automatic + retry). The user can broadcast a failed transaction manually later. + - Note: `get_willexecutor_transactions` already excludes PUSHED transactions, + so the successful ones are never re-sent on a later run. + +- `tests/test_group_b_auto_sign.py` + - Rewritten for the new behaviour: AUTO_SIGN default/disable; manual hint + suppressed when AUTO_SIGN ON and shown when OFF; PUSHED transactions are not + re-collected for broadcast (one-shot), while not-yet-PUSHED ones are. + +**Verification:** +- `ruff check` on changed Python files: no new errors introduced + (new test file is ruff-clean). +- Full test suite: `206 passed`. + +**Outcome:** DONE. diff --git a/bal/VERSION b/bal/VERSION index 87a0871..42045ac 100644 --- a/bal/VERSION +++ b/bal/VERSION @@ -1 +1 @@ -0.3.3 \ No newline at end of file +0.3.4 diff --git a/bal/__init__.py b/bal/__init__.py index a814720..b657cf3 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing ``json_db.register_dict``) and PyQt6. """ -__version__ = "0.3.3" +__version__ = "0.3.4" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index aa0b7cf..9f98afb 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -91,7 +91,7 @@ class BalPlugin(BasePlugin): """ _version = None - __version__ = "0.3.3" # AUTOMATICALLY GENERATED DO NOT EDIT + __version__ = "0.3.4" # AUTOMATICALLY GENERATED DO NOT EDIT # Command used to open an .ics calendar file, per operating system. default_app = { @@ -146,8 +146,17 @@ class BalPlugin(BasePlugin): self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True) self.BROADCAST = BalConfig(config, "bal_broadcast", True) self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90) + # NOTE (A1): block-height locktimes were removed; the plugin now uses + # only timestamp-based locktimes. LOCKTIME_BLOCKS is therefore no longer + # read anywhere in the code. It is kept here (dormant) on purpose, to + # avoid touching a persisted config key ("bal_locktime_blocks") that may + # already exist in some users' saved settings. self.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90) self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7) + # NOTE (A1): same as LOCKTIME_BLOCKS above - block-height locktimes were + # removed, so LOCKTIMEDELTA_BLOCKS is no longer read anywhere. It is kept + # here (dormant) on purpose, to avoid touching the persisted config key + # "bal_locktimedelta_blocks" that may already exist in saved settings. self.LOCKTIMEDELTA_BLOCKS = BalConfig( config, "bal_locktimedelta_blocks", 144 * 7 ) @@ -158,6 +167,14 @@ class BalPlugin(BasePlugin): self.PREVIEW = BalConfig(config, "bal_preview", True) self.SAVE_TXS = BalConfig(config, "bal_save_txs", True) + # AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after + # querying the will-executor servers, automatically sign the will + # transactions and broadcast them to their will-executors, without the + # user having to invoke "Sign" and "Broadcast" separately. The wallet + # password is requested only when the wallet is actually encrypted + # (handled by BalWindow.get_wallet_password). Default ON. + self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True) + self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) diff --git a/bal/core/util.py b/bal/core/util.py index 32d7ec1..c1070fa 100644 --- a/bal/core/util.py +++ b/bal/core/util.py @@ -24,7 +24,13 @@ from electrum.transaction import PartialTxOutput # Bitcoin consensus rule: an nLockTime value strictly below this threshold is # interpreted as a *block height*, otherwise it is interpreted as a *UNIX -# timestamp*. This single constant drives most of the locktime handling below. +# timestamp*. +# +# The plugin now uses ONLY timestamp-based locktimes (block-height locktimes +# were removed so that every locktime can be compared and ordered consistently). +# This constant is kept as a guard: it is the boundary that lets us reject any +# value that would fall in the block-height range and force every locktime to be +# a timestamp. LOCKTIME_THRESHOLD = 500000000 @@ -56,11 +62,16 @@ class Util: def str_to_locktime(locktime): """Parse a user-entered locktime string into its stored form. - Relative values keep their suffix (``"30d"``, ``"1y"``, ``"144b"``); - absolute ISO dates are converted to an integer UNIX timestamp. + Relative values keep their suffix (``"30d"``, ``"1y"``); absolute ISO + dates are converted to an integer UNIX timestamp. + + Note: only timestamp-based locktimes are supported. The legacy + block-height suffix ``"b"`` has been removed on purpose, so that every + locktime in the plugin is a UNIX timestamp and can always be compared + and ordered consistently. """ try: - if locktime[-1] in ("y", "d", "b"): + if locktime[-1] in ("y", "d"): return locktime else: return int(locktime) @@ -78,8 +89,12 @@ class Util: * plain int / timestamp -> returned unchanged * ``"y"`` -> n years from now (as a timestamp) * ``"d"`` -> n days from now (as a timestamp) - * ``"b"`` -> current block height + n (needs wallet - ``w`` to read the chain height) + + Note: the legacy block-height form ``"b"`` has been removed on + purpose. Every locktime is now a UNIX timestamp, so locktimes can + always be compared and ordered consistently. The optional ``w`` + (wallet) argument is kept only for backward call-site compatibility and + is no longer used. """ try: return int(locktime) @@ -96,26 +111,23 @@ class Util: .replace(hour=0, minute=0, second=0, microsecond=0) .timestamp() ) - if locktime[-1] == "b": - locktime = int(locktime[:-1]) - height = 0 - if w: - height = Util.get_current_height(w.network) - locktime += int(height) return int(locktime) except Exception: pass return 0 @staticmethod - def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0): - """Convert a human duration into seconds (blocks counted as 600s each).""" + def int_locktime(seconds=0, minutes=0, hours=0, days=0): + """Convert a human duration into seconds. + + Note: the ``blocks`` argument was removed together with block-height + support; every duration is now expressed in plain time units. + """ return int( seconds + minutes * 60 + hours * 60 * 60 + days * 60 * 60 * 24 - + blocks * 600 ) # ------------------------------------------------------------------ # @@ -337,43 +349,35 @@ class Util: # Locktime arithmetic # ------------------------------------------------------------------ # @staticmethod - def chk_locktime(timestamp_to_check, block_height_to_check, locktime): - """Return True if ``locktime`` is still in the future. + def chk_locktime(timestamp_to_check, locktime): + """Return True if ``locktime`` (a UNIX timestamp) is still in the future. - Timestamp-style and block-height-style locktimes are compared against - the respective "to_check" reference value. + Only timestamp-based locktimes are supported now; the previous + block-height branch was removed together with block-height support. """ - # TODO BUG: WHAT HAPPEN AT THRESHOLD? locktime = int(locktime) - if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check: - return True - elif locktime < LOCKTIME_THRESHOLD and locktime > block_height_to_check: - return True - else: - return False + return locktime > int(timestamp_to_check) @staticmethod - def anticipate_locktime(locktime, blocks=0, hours=0, days=0): - """Move a locktime earlier by the given amount. + def anticipate_locktime(locktime, hours=0, days=0): + """Move a timestamp locktime earlier by the given amount. - Works on both timestamp and block-height locktimes; never returns a - value below 1. + Every locktime is a UNIX timestamp now, so this simply subtracts the + requested time span. The result is never allowed to drop below 1. + + Note: the legacy ``blocks`` argument and the block-height branch were + removed; only timestamp arithmetic remains. """ locktime = int(locktime) - out = 0 - if locktime > LOCKTIME_THRESHOLD: - seconds = blocks * 600 + hours * 3600 + days * 86400 - # On Windows datetime.fromtimestamp raises OverflowError past 2038 - # (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170). - try: - dt = datetime.fromtimestamp(locktime) - except (OverflowError, OSError, ValueError): - dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) - dt -= timedelta(seconds=seconds) - out = dt.timestamp() - else: - blocks -= hours * 6 + days * 144 - out = locktime + blocks + seconds = hours * 3600 + days * 86400 + # On Windows datetime.fromtimestamp raises OverflowError past 2038 + # (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170). + try: + dt = datetime.fromtimestamp(locktime) + except (OverflowError, OSError, ValueError): + dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) + dt -= timedelta(seconds=seconds) + out = dt.timestamp() if out < 1: out = 1 diff --git a/bal/core/will.py b/bal/core/will.py index 85d6782..6ead271 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -28,7 +28,6 @@ The status flags themselves (the source of truth) stay here; only the mapping import copy -from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX from electrum.i18n import _ from electrum.logging import Logger, get_logger from electrum.transaction import ( @@ -458,7 +457,7 @@ class Will: if ( wi.get_status("VALID") or wi.get_status("CONFIRMED") - or wi.get_status("PENDING") + or wi.get_status("MEMPOOL") ): prevout_id = w[2].prevout.txid.hex() if not inutxo: @@ -506,7 +505,7 @@ class Will: if ( not w.father or willtree[w.father].get_status("CONFIRMED") - or willtree[w.father].get_status("PENDING") + or willtree[w.father].get_status("MEMPOOL") ): for inp in w.tx.inputs(): inp_str = Util.utxo_to_str(inp) @@ -516,7 +515,7 @@ class Will: if height < 0: Will.set_invalidate(wid, willtree) elif height == 0: - w.set_status("PENDING", True) + w.set_status("MEMPOOL", True) else: w.set_status("CONFIRMED", True) @@ -558,7 +557,20 @@ class Will: ) @staticmethod - def check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check): + def check_will(will, all_utxos, wallet, timestamp_to_check): + """Validate a will against the current wallet state. + + Locktimes are always UNIX timestamps (block-height locktimes are no + longer supported by this plugin), so expiry is decided purely by + comparing each transaction's locktime against ``timestamp_to_check``. + + Args: + will: The will dictionary (WillItem entries keyed by txid). + all_utxos: The list of UTXOs currently available in the wallet. + wallet: The Electrum wallet object. + timestamp_to_check: The reference UNIX timestamp (usually "now") + used to decide whether any transaction has expired. + """ Will.add_willtree(will) utxos_list = Will.utxos_strs(all_utxos) @@ -566,9 +578,7 @@ class Will: all_inputs = Will.get_all_inputs(will, only_valid=True) all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs) - Will.check_will_expired( - all_inputs_min_locktime, block_to_check, timestamp_to_check - ) + Will.check_will_expired(all_inputs_min_locktime, timestamp_to_check) all_inputs = Will.get_all_inputs(will, only_valid=True) @@ -583,7 +593,6 @@ class Will: @staticmethod def is_will_valid( will, - block_to_check, timestamp_to_check, tx_fees, all_utxos, @@ -593,10 +602,29 @@ class Will: wallet=False, callback_not_valid_tx=None, ): + """Check whether the whole will is valid at the given timestamp. + + Locktimes are always UNIX timestamps, so the validity check only needs + a single reference timestamp (no block height). + + Args: + will: The will dictionary (WillItem entries keyed by txid). + timestamp_to_check: Reference UNIX timestamp (usually "now"). + tx_fees: Fee rate used for the dust/coverage check. + all_utxos: The list of UTXOs currently available in the wallet. + heirs: Optional heirs dictionary. + willexecutors: Optional will-executors dictionary. + self_willexecutor: Whether the user acts as their own executor. + wallet: The Electrum wallet object. + callback_not_valid_tx: Optional callback invoked for invalid txs. + + Returns: + True if the will is valid; raises an exception otherwise. + """ heirs = heirs if heirs is not None else {} willexecutors= willexecutors if willexecutors is not None else {} - Will.check_will(will, all_utxos, wallet, block_to_check, timestamp_to_check) + Will.check_will(will, all_utxos, wallet, timestamp_to_check) if heirs: if not Will.check_willexecutors_and_heirs( will, @@ -625,25 +653,30 @@ class Will: return True @staticmethod - def check_will_expired(all_inputs_min_locktime, block_to_check, timestamp_to_check): + def check_will_expired(all_inputs_min_locktime, timestamp_to_check): + """Raise WillExpiredException if any valid transaction has expired. + + Locktimes are always UNIX timestamps, so a transaction is expired when + its locktime is in the past relative to ``timestamp_to_check``. + + Args: + all_inputs_min_locktime: Mapping prevout -> will-item info, used to + find the minimum locktime per input. + timestamp_to_check: Reference UNIX timestamp (usually "now"). + """ _logger.info("check if some transaction is expired") for prevout_str, wid in all_inputs_min_locktime.items(): for w in wid: if w[1].get_status("VALID"): locktime = int(wid[0][1].tx.locktime) - if locktime <= NLOCKTIME_BLOCKHEIGHT_MAX: - if locktime < int(block_to_check): - raise WillExpiredException( - f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}" - ) + # Locktimes are always timestamps: expired when in the past. + if locktime < int(timestamp_to_check): + raise WillExpiredException( + f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}" + ) else: - if locktime < int(timestamp_to_check): - raise WillExpiredException( - f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}" - ) - else: - from datetime import datetime - _logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}") + from datetime import datetime + _logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}") # def check_all_input_spent_are_in_wallet(): # _logger.info("check all input spent are in wallet or valid txs") @@ -723,11 +756,24 @@ class Will: f"{tx_locktime}->{new_locktime} " f"on a signed/sent will" ) - # new_locktime < tx_locktime (anticipate) is left to - # check_will_expired -> WillExpiredException. + # ANTICIPATE (new_locktime < tx_locktime): the user + # manually moved the delivery date EARLIER. + # * If the new date is still in the FUTURE, this is + # a plain ANTICIPATE: it falls through here and is + # rebuilt via HeirNotFoundException (no on-chain + # fee). It must NEVER invalidate on-chain, even if + # the will was already signed/sent (A3, owner + # decision D2 = A1). + # * If the new date is in the PAST (relative to the + # check date) the will is genuinely expired and + # check_will_expired -> WillExpiredException handles + # it (on-chain invalidation). That is a different + # situation from "anticipate" and is intentionally + # kept. + # # new_locktime > tx_locktime on a will that was never - # signed/sent falls through here -> a plain rebuild via - # HeirNotFoundException (no on-chain fee needed). + # signed/sent also falls through here -> a plain rebuild + # via HeirNotFoundException (no on-chain fee needed). else: # The will still carries this heir, but the heir is no # longer present in the current heirs set: the user @@ -774,6 +820,18 @@ class Will: class WillItem(Logger): + # Default status flags for an inheritance transaction. + # Each entry maps an internal status key to [human-readable label, default + # boolean value]. + # + # A2 changes: + # * "PENDING" was renamed to "MEMPOOL" (the transaction has been seen in + # the Electrum mempool). Old saved wills that still carry the legacy + # "PENDING" key are migrated to "MEMPOOL" in __init__ (see below), so + # nothing is lost. + # * "UPDATED" was added: the transaction was spendable AND valid, and a new + # transaction replaces it while keeping the SAME locktime and SAME heirs. + # UPDATED keeps the VALID flag (see set_status). STATUS_DEFAULT = { "ANTICIPATED": ["Anticipated", False], "BROADCASTED": ["Broadcasted", False], @@ -786,30 +844,57 @@ class WillItem(Logger): "EXPORTED": ["Exported", False], "IMPORTED": ["Imported", False], "INVALIDATED": ["Invalidated", False], - "PENDING": ["Pending", False], + "MEMPOOL": ["Mempool", False], "PUSH_FAIL": ["Push failed", False], "PUSHED": ["Pushed", False], "REPLACED": ["Replaced", False], "RESTORED": ["Restored", False], + "UPDATED": ["Updated", False], "VALID": ["Valid", True], } def set_status(self, status, value=True): - # _logger.trace( - # "set status {} - {} {} -> {}".format( - # self._id, status, self.STATUS[status][1], value - # ) - # ) + """Set a status flag and apply the related side effects. + + Some statuses imply that other statuses must change. The rules below + match the inheritance state machine: + + VALID handling: + * INVALIDATED, REPLACED, CONFIRMED, MEMPOOL -> clear VALID + (the transaction can no longer be delivered as a valid will tx). + * ANTICIPATED -> KEEPS VALID. Anticipating only moves the locktime + earlier by 1 day; the transaction stays valid (it is NOT in the + "clear VALID" list on purpose). + * UPDATED -> KEEPS VALID. The transaction is replaced by a new one + that keeps the SAME locktime and SAME heirs, so it stays valid + (it is NOT in the "clear VALID" list on purpose). + + Other side effects: + * CONFIRMED, MEMPOOL -> clear INVALIDATED (the tx is on-chain or in + the mempool, so it is no longer considered invalidated). + * PUSHED -> clear PUSH_FAIL and CHECK_FAIL. + * CHECKED -> set PUSHED and clear PUSH_FAIL. + + Args: + status: The status key to set (must exist in STATUS). + value: True to set the flag, False to clear it. Defaults to True. + + Returns: + The applied boolean value, or None if the flag was already set to + that value (no change). + """ if self.STATUS[status][1] == bool(value): return None self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0])) self.STATUS[status][1] = bool(value) if value: - if status in ["INVALIDATED", "REPLACED", "CONFIRMED", "PENDING"]: + # NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list, + # so they keep the VALID flag (see docstring above). + if status in ["INVALIDATED", "REPLACED", "CONFIRMED", "MEMPOOL"]: self.STATUS["VALID"][1] = False - if status in ["CONFIRMED", "PENDING"]: + if status in ["CONFIRMED", "MEMPOOL"]: self.STATUS["INVALIDATED"][1] = False if status in ["PUSHED"]: @@ -845,6 +930,14 @@ class WillItem(Logger): self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) for s in self.STATUS: self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1]) + # Backward-compatibility migration (A2): the "PENDING" status was + # renamed to "MEMPOOL". Wills saved by older versions of the plugin + # store the flag under the legacy "PENDING" key, so if that key is + # present and set, carry it over to "MEMPOOL". This way no state is + # lost when loading an older will. The new key always wins if both + # happen to be present. + if "MEMPOOL" not in w and w.get("PENDING"): + self.STATUS["MEMPOOL"][1] = True if not _id: self._id = self.tx.txid() else: diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 44cdb09..7eb9e68 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -720,10 +720,18 @@ class BalBuildWillDialog(BalDialog): self.msg_set_invalidating(self.msg_error(e)) def loop_push(self): + # Broadcast is "one-shot" (Group B / B2 follow-up): each selected + # will-executor is contacted ONCE. Transactions that are broadcast + # successfully become PUSHED; transactions whose server fails or times + # out are left as PUSH_FAIL and simply skipped - they are NOT retried + # automatically. A dead will-executor could otherwise never answer and + # make the plugin retry forever. The user can broadcast a failed + # transaction manually later with the "Broadcast" button. Note that + # get_willexecutor_transactions already excludes PUSHED transactions, so + # the successful ones are never re-sent on a subsequent run. if self._stopping: return self.msg_set_pushing(_("Broadcasting")) - retry = False try: willexecutors = Willexecutors.get_willexecutor_transactions( @@ -744,7 +752,6 @@ class BalBuildWillDialog(BalDialog): # them sequentially after the parallel push, keeping the original # check logic untouched. already_present = [] - retry_flag = {"value": False} total = len(selected) done = {"count": 0} @@ -770,9 +777,10 @@ class BalBuildWillDialog(BalDialog): for wid in willexecutor["txsids"]: self.bal_window.willitems[wid].set_status("PUSHED", True) else: + # One-shot: mark the failed transactions and move on. They + # are left as PUSH_FAIL (no automatic retry). for wid in willexecutor["txsids"]: self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) - retry_flag["value"] = True done["count"] += 1 # Show the per-server result (Ok/Ko) in bold + color so the # outcome stands out, keeping the server URL in normal weight. @@ -781,11 +789,11 @@ class BalBuildWillDialog(BalDialog): self.msg_set_pushing(_status_line()) def on_timeout(url, willexecutor): - # The global deadline elapsed before this server answered. Mark - # its txs as failed (so the user can retry later) and show it. + # The global deadline elapsed before this server answered. Mark + # its txs as failed and move on (one-shot: no automatic retry). + # The user can broadcast them manually later if desired. for wid in willexecutor.get("txsids", []): self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) - retry_flag["value"] = True self.msg_edit_row( "{} : {}".format(url, self.msg_error(_("Timeout - no answer"))) ) @@ -820,7 +828,6 @@ class BalBuildWillDialog(BalDialog): "{}/{} ({}s)".format(done["count"], total, int(time.time() - push_start)) ) - retry = retry_flag["value"] # Verify the "already present" servers (sequential, original logic). self.bal_plugin = self.bal_window.bal_plugin @@ -851,15 +858,18 @@ class BalBuildWillDialog(BalDialog): row, ) - if retry: - raise Exception("retry") + # One-shot broadcast: we deliberately do NOT raise/retry when some + # will-executors failed. Their transactions stay PUSH_FAIL and are + # left for the user to broadcast manually. This prevents an endless + # retry loop against a will-executor that may never answer. except Exception as e: + # Only genuine, unexpected errors reach here now (not the old + # "retry" signal). Report the error; do not loop. self.msg_set_pushing(self.msg_error(e)) self.wait(10) if not self._stopping: pass - # self.loop_push() def invalidate_task(self, password, bal_window, tx): if self._stopping: @@ -1038,6 +1048,18 @@ class BalBuildWillDialog(BalDialog): short to be sure the user noticed the in-dialog line). """ self._next_steps_hint = None + # Group B / B2: when AUTO_SIGN is ON the dialog has already signed and + # broadcast the will automatically, so the manual "press Sign/Broadcast" + # hints (and the follow-up popup) would be wrong/confusing. Suppress + # them in that case. When AUTO_SIGN is OFF, keep the previous behaviour + # and guide the user through the remaining manual steps. + try: + if self.bal_window.bal_plugin.AUTO_SIGN.get(): + return + except Exception: + # If the setting cannot be read for any reason, fall back to the + # original behaviour (show the manual hints). + pass try: need_sign = False need_push = False diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index b853191..08fdc71 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -551,6 +551,12 @@ class PreviewList(MyTreeView, MessageBoxMixin): if will: self.bal_window.check_transactions(will) self.update() + # NOTE (Group B / B2): signing + broadcasting is already performed + # automatically by BalBuildWillDialog.build_will_task() above (called at + # the start of check()). We must NOT trigger a second sign/broadcast + # cycle here, otherwise the will would be broadcast twice. Whether the + # automatic sign/broadcast runs silently or shows the manual "next step" + # hints is controlled by the AUTO_SIGN setting inside that dialog. def invalidate_will(self): self.bal_window.invalidate_will() diff --git a/bal/gui/qt/plugin.py b/bal/gui/qt/plugin.py index d52e588..bef91ae 100644 --- a/bal/gui/qt/plugin.py +++ b/bal/gui/qt/plugin.py @@ -389,6 +389,13 @@ class Plugin(BalPlugin): heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change) heir_hide_invalidated = BalCheckBox(self.HIDE_INVALIDATED, on_multiverse_change) + + # Auto-sign checkbox (Group B / B2). When ticked, the "Check" action + # automatically signs and broadcasts the will after querying the + # will-executor servers. Bound to the persisted AUTO_SIGN config; the + # default is ON (see plugin_base.py). + heir_auto_sign = BalCheckBox(self.AUTO_SIGN) + heir_repush = QPushButton("Rebroadcast transactions") heir_repush.clicked.connect(partial(self.broadcast_transactions, True)) bal_mode = QComboBox() @@ -410,18 +417,30 @@ class Plugin(BalPlugin): 2, "Hide invalidated transactions from will detail and list", ) + add_widget( + grid, + "Auto-sign on Check", + heir_auto_sign, + 3, + ( + "When checking, automatically sign and broadcast the will " + "transactions to their will-executors.\n" + "The wallet password is requested only if the wallet is " + "encrypted." + ), + ) add_widget( grid, "Calendar App", BalLineEdit(self.CALENDAR_APP), - 3, + 4, "Default app used to open calendar", ) add_widget( grid, "Event summary", BalLineEdit(self.EVENT_SUMMARY), - 4, + 5, ( "Default message to be used in event summary\n" "Variables:\n" @@ -432,9 +451,9 @@ class Plugin(BalPlugin): ) add_widget( grid, - "Event sescription", + "Event description", BalTextEdit(self.EVENT_DESCRIPTION), - 5, + 6, ( "Default message to be used in event description\n" "Variables:\n" diff --git a/bal/gui/qt/theme.py b/bal/gui/qt/theme.py index 33951a4..08ea1ac 100644 --- a/bal/gui/qt/theme.py +++ b/bal/gui/qt/theme.py @@ -22,8 +22,9 @@ status into a colour for the transaction list / detail views. _STATUS_COLOR_PRIORITY = ( ("INVALIDATED", "#f87838"), # orange - tx can no longer be mined ("REPLACED", "#ff97e9"), # pink - superseded by another tx + ("UPDATED", "#b266b2"), # light violet - replaced keeping same locktime+heirs ("CONFIRMED", "#bfbfbf"), # grey - already mined - ("PENDING", "#ffce30"), # yellow - in mempool, waiting + ("MEMPOOL", "#ffce30"), # yellow - seen in the Electrum mempool ) # Default colour used when no status in the priority list matches. diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 487a083..31c8d00 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -347,12 +347,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor): self.textChanged.connect(self.numbify) self.isdays = False self.isyears = False - self.isblocks = False self.time_edit = time_edit @staticmethod def replace_str(text): - return str(text).replace("d", "").replace("y", "").replace("b", "") + """Strip the relative-time suffixes (d/y) from the text. + + Only days ("d") and years ("y") are supported. The block-height + suffix ("b") was removed (A1): locktimes are always timestamps now. + """ + return str(text).replace("d", "").replace("y", "") def checkbdy(self, s, pos, appendix): try: @@ -367,33 +371,29 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor): return pos, s def numbify(self): + # Only digits plus the day ("d") and year ("y") suffixes are accepted. + # The block-height suffix ("b") was removed (A1): locktimes are always + # UNIX timestamps now, so block-relative input is no longer allowed. text = self.text().strip() - # chars = '0123456789bdy' removed the option to choose locktime by block chars = "0123456789dy" pos = self.cursorPosition() pos = len("".join([i for i in text[:pos] if i in chars])) s = "".join([i for i in text if i in chars]) self.isdays = False self.isyears = False - self.isblocks = False pos, s = self.checkbdy(s, pos, "d") pos, s = self.checkbdy(s, pos, "y") - pos, s = self.checkbdy(s, pos, "b") if "d" in s: self.isdays = True if "y" in s: self.isyears = True - if "b" in s: - self.isblocks = True if self.isdays: s = self.replace_str(s) + "d" if self.isyears: s = self.replace_str(s) + "y" - if self.isblocks: - s = self.replace_str(s) + "b" self.blockSignals(True) self.setText(s) self.blockSignals(False) @@ -420,6 +420,11 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor): class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor): + # GUARD (kept on purpose, A1): NLOCKTIME_BLOCKHEIGHT_MAX is the highest value + # Bitcoin interprets as a *block height*. By forcing the minimum to one above + # it, every locktime entered here is guaranteed to be a UNIX *timestamp*, + # never a block height. This is NOT block-height ordering; it is the + # "bouncer" that prevents block-height values from ever being used again. min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1 max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp() diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 8c27d24..e088177 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -368,7 +368,6 @@ class BalWindow: def check_will(self): return Will.is_will_valid( self.willitems, - self.block_to_check, self.date_to_check, self.will_settings["baltx_fees"], self.window.wallet.get_utxos(), @@ -459,9 +458,10 @@ class BalWindow: try: self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() # found = False - self.locktime_blocks = self.bal_plugin.LOCKTIME_BLOCKS.get() - self.current_block = Util.get_current_height(self.wallet.network) - self.block_to_check = 0 + # NOTE: block-height tracking removed (A1) - locktimes are always + # UNIX timestamps now, so we no longer read the current block height + # or compute a block_to_check here. Validity is decided purely by + # comparing locktimes against date_to_check (a timestamp). self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get() self.willexecutors = Willexecutors.get_willexecutors( self.bal_plugin, update=True, bal_window=self, task=False @@ -479,6 +479,10 @@ class BalWindow: def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True): try: + _logger.info( + "BAL-plugin \u25b8 STEP 1/7: prepare inheritance " + "(validate settings, amounts and locktime)" + ) if self.disable_plugin: _logger.info("plugin is disabled") return @@ -523,11 +527,26 @@ class BalWindow: return try: + _logger.info( + "BAL-plugin \u25b8 STEP 2/7: checking if the current will is " + "still coherent (heirs, will-executors, fees, locktime)" + ) self.check_will() + _logger.info( + "BAL-plugin \u25b8 STEP 2/7 result: will is COHERENT, " + "nothing to rebuild" + ) except WillExpiredException: + _logger.info( + "BAL-plugin \u25b8 STEP 2/7 result: will EXPIRED -> " + "invalidating on-chain (real fee)" + ) self.invalidate_will() return except NoHeirsException: + _logger.info( + "BAL-plugin \u25b8 STEP 2/7 result: no valid heirs -> abort" + ) return except WillPostponedException as e: # The will was already signed/sent and is being postponed. @@ -536,6 +555,10 @@ class BalWindow: # can never be used by a will-executor), then press "Prepare" # again # to create the new postponed inheritance. + _logger.info( + "BAL-plugin \u25b8 STEP 2/7 result: will POSTPONED on a " + "signed/sent tx -> must invalidate on-chain first (real fee)" + ) _logger.info(f"will postponed: {e}") self.show_message( _( @@ -553,6 +576,10 @@ class BalWindow: self.invalidate_will() return except NotCompleteWillException as e: + _logger.info( + "BAL-plugin \u25b8 STEP 2/7 result: will NOT coherent -> " + "REBUILD needed (no on-chain fee)" + ) _logger.info("{}:{}".format(type(e), e)) message = False if isinstance(e, HeirChangeException): diff --git a/bal/manifest.json b/bal/manifest.json index fa0fe4b..4ce9686 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "fullname": "Bitcoin After Life", - "version": "0.3.3", + "version": "0.3.4", "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.", "author": "Svatantrya", "licence": "MIT", diff --git a/docs/images/inheritance-flow.svg b/docs/images/inheritance-flow.svg index 02fee78..a7c3edb 100644 --- a/docs/images/inheritance-flow.svg +++ b/docs/images/inheritance-flow.svg @@ -23,7 +23,7 @@ BAL — Inheritance change decision flow - What happens to your pre-signed transactions when you change the will (v0.3.3) + What happens to your pre-signed transactions when you change the will (v0.3.4) @@ -63,32 +63,32 @@ No - + - VALID tx with locktime - earlier than the new date? (anticipate) + New delivery date + already in the PAST? - + - Already signed - or sent? + Moved the date + EARLIER? (anticipate) - Yes (earlier) + No (still future) - + - Rebuild only - no on-chain cost + Rebuild only + no on-chain cost - NEVER invalidates - Not signed + Yes (signed or not) - + Invalidate on-chain FIRST - WillExpired - - Signed + WillExpired (date in the past) + + Yes (past) diff --git a/docs/inheritance-options.html b/docs/inheritance-options.html index 2265429..201868b 100644 --- a/docs/inheritance-options.html +++ b/docs/inheritance-options.html @@ -38,6 +38,12 @@ .pill.blue{background:var(--blue)} .pill.green{background:var(--green);color:#fff} .pill.grey{background:var(--grey);color:#fff} + /* Status colours matching gui/qt/theme.py exactly. */ + .pill.orange{background:#f87838;color:#0d1117} + .pill.pink{background:#ff97e9;color:#0d1117} + .pill.violet{background:#b266b2;color:#fff} + .pill.yellow{background:#ffce30;color:#0d1117} + .pill.teal{background:#73f3c8;color:#0d1117} .mermaid{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:18px;margin:1.2em 0;overflow-x:auto} .lead{color:var(--muted);font-size:1.05rem} footer{margin-top:3em;color:var(--muted);font-size:.85rem;border-top:1px solid var(--border);padding-top:1em} @@ -78,37 +84,47 @@ transaction that would execute your inheritance too early. - + - + - + + +
StatusMeaningSet when
VALIDThe current, usable plandefault; cleared by INVALIDATED/REPLACED/CONFIRMED/PENDING
VALIDThe current, usable plandefault; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL
COMPLETE (Signed)The transaction has been signedafter Sign
PUSHEDSigned tx sent to executor(s)after Broadcast to executors
CHECKEDExecutor confirmed it holds the txafter a successful server Check (implies PUSHED)
CHECK_FAILServer check faileda queried executor did not return the tx
PUSH_FAILSending to the executor failedcleared when PUSHED becomes true
CONFIRMEDTx mined on‑chainseen on‑chain, height > 0
PENDINGTx in the mempoolseen on‑chain, height 0
MEMPOOLTx in the Electrum mempoolseen on‑chain, height 0 (named PENDING before v0.3.4)
INVALIDATEDInputs spent → can never confirminvalidation tx / inputs gone
REPLACEDSuperseded by an earlier‑locktime childa replacing child found
REPLACEDSuperseded by an earlier‑locktime childa replacing child found
ANTICIPATEDLocktime anticipated by 1 day vs a pre‑existing tx with the same heirsset_anticipate (tx stays VALID)
UPDATEDReplaced by a new tx keeping the same locktime + same heirssame‑locktime replacement (tx stays VALID)
EXPIREDLocktime already in the past vs the check datecheck_will_expired

Safety rules baked into set_status

    -
  • Setting INVALIDATED / REPLACED / CONFIRMED / PENDING → clears VALID.
  • -
  • Setting CONFIRMED / PENDING → clears INVALIDATED.
  • +
  • Setting INVALIDATED / REPLACED / CONFIRMED / MEMPOOL → clears VALID.
  • +
  • Setting ANTICIPATEDkeeps VALID (only moves the locktime 1 day earlier).
  • +
  • Setting UPDATEDkeeps VALID (same locktime + same heirs).
  • +
  • Setting CONFIRMED / MEMPOOL → clears INVALIDATED.
  • Setting PUSHED → clears PUSH_FAIL and CHECK_FAIL.
  • Setting CHECKED → implies PUSHED.

How states map to row colour

- + - - - - - + + + + + + + + + + +
State (first match wins)ColourHex
PriorityStateColourHex
CHECK_FAILred#e83845
INVALIDATED / REPLACEDgreymuted
CONFIRMEDgreenconfirmed
COMPLETE (signed, not pushed)blue#2bc8ed
VALID (prepared, not signed)default
1INVALIDATEDorange#f87838
2REPLACEDpink#ff97e9
3UPDATEDlight violet#b266b2
4CONFIRMEDgrey#bfbfbf
5MEMPOOLyellow#ffce30
6CHECK_FAIL (and not CHECKED)red#e83845
7CHECKEDgreen#8afa6c
8PUSH_FAILred#e83845
9PUSHEDteal#73f3c8
10COMPLETE (signed, not pushed)blue#2bc8ed
none of the above (plain VALID)default white#ffffff
v0.3.3 fix: a will that is signed but not yet broadcast @@ -125,13 +141,13 @@ flowchart TD B -- No --> Z1[/Show: Heirs are not defined — stop/] B -- Yes --> C{Check-Alive threshold in the future?} C -- "No, it's in the past" --> INV1[[Invalidate on-chain
CheckAliveError]] - C -- Yes --> D{Any VALID tx with locktime earlier than the new date?} + C -- Yes --> D{New delivery date already in the PAST?} - D -- "Yes you moved the date EARLIER / anticipate" --> E{Was that tx already signed or sent?} - E -- "Not signed yet" --> R1[[Rebuild only
no on-chain cost]] - E -- "Signed / sent" --> INV2[[Invalidate on-chain FIRST
WillExpired]] + D -- "Yes date is now expired" --> INV2[[Invalidate on-chain FIRST
WillExpired]] + D -- "No date still in the future" --> AN{Did you move the date EARLIER anticipate?} - D -- No --> F{Will-executor / fee / heirs unchanged?} + AN -- "Yes anticipate — signed or not" --> R1[[Rebuild only
no on-chain cost
NEVER invalidates]] + AN -- No --> F{Will-executor / fee / heirs unchanged?} F -- "Fee changed" --> R2[[Rebuild
TxFeesChanged]] F -- "Will-executor changed/absent" --> R3[[Rebuild
WillExecutorNotPresent / Change]] F -- "Heir added/removed, % or address changed" --> G{POSTPONE of an already signed/sent tx?} @@ -165,13 +181,20 @@ already‑signed transaction (w.tx.locktime) — exactly w Move date LATER (postpone)No (never signed)Plain rebuildNo Move date LATER (postpone)YesInvalidate first, then rebuild (WillPostponed)Yes -Move date EARLIER (anticipate)anyOld tx expired → invalidate (WillExpired)Yes +Move date EARLIER, still in the future (anticipate)any (signed or not)Plain rebuild with the new earlier locktime — never invalidatesNo +Move date EARLIER into the past (new date already passed)—Will is genuinely expired → invalidate (WillExpired)Yes Check‑Alive threshold already passed—Invalidate (CheckAliveError)Yes
Why postpone needs on‑chain invalidation: the executor still holds the old transaction with the earlier locktime. Spending its inputs on‑chain makes it un‑minable, so it can never execute the inheritance early. The plugin tells you this and offers to build the invalidation tx.
+
Why anticipate (move earlier, still future) is NOT on‑chain: moving the delivery +date earlier only makes the inheritance available sooner; there is no early‑execution risk, +so the plugin simply rebuilds the transactions with the new earlier locktime — no +on‑chain invalidation and no Bitcoin fee. This holds even if the will was already +signed/sent: anticipating never invalidates. Only a date that lands in the past is treated +as expired and invalidated (WillExpired).

4.2 Adding an heir

An heir present in your set but not yet in the will raises HeirNotFoundException.

@@ -237,11 +260,12 @@ executor that should hold your tx did not return it — re‑Broadcast Change % / address (only prepared)YesNo Change fee rateYesNo Change / remove will‑executorYesNo -Move date earlier (anticipate)Yes afterYes +Move date earlier, still in the future (anticipate) — signed or notYesNo +Move date earlier into the past (new date already passed)Yes afterYes Move date later — will signed/sentYes afterYes Move date later — will only preparedYesNo Check‑Alive threshold already passedYes afterYes -Any change to an already signed/sent willYes afterYes +Any change to an already signed/sent will that postpones it or expires itYes afterYes Nothing changedNoNo @@ -249,13 +273,16 @@ executor that should hold your tx did not return it — re‑Broadcast

7. Golden rules

  1. Before it's signed, changing anything is free — just Prepare again.
  2. -
  3. After it's signed/sent, moving the date or replacing it requires an on‑chain - invalidation first (a small Bitcoin fee) so an old transaction can never execute early.
  4. +
  5. After it's signed/sent, only postponing the date (moving it later), + or letting it expire (a date now in the past), requires an on‑chain invalidation + first (a small Bitcoin fee) so an old transaction can never execute early. + Anticipating (moving the date earlier, still in the future) never invalidates — + it is just a free rebuild.
  6. Always finish with Sign → Broadcast → Check so executors hold the current plan (green).
  7. The wallet is always fully emptied by the inheritance, so heir amounts must add up.
-