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.
This commit is contained in:
2026-06-28 23:00:23 -04:00
parent 10d0b85779
commit dc166d04ff
23 changed files with 1374 additions and 205 deletions

380
CHANGELOG.md Normal file
View File

@@ -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.
---
<!-- New entries are added below, newest last. -->
## 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 (`"<n>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.

View File

@@ -1 +1 @@
0.3.3 0.3.4

View File

@@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing
``json_db.register_dict``) and PyQt6. ``json_db.register_dict``) and PyQt6.
""" """
__version__ = "0.3.3" __version__ = "0.3.4"

View File

@@ -91,7 +91,7 @@ class BalPlugin(BasePlugin):
""" """
_version = None _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. # Command used to open an .ics calendar file, per operating system.
default_app = { default_app = {
@@ -146,8 +146,17 @@ class BalPlugin(BasePlugin):
self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True) self.ASK_BROADCAST = BalConfig(config, "bal_ask_broadcast", True)
self.BROADCAST = BalConfig(config, "bal_broadcast", True) self.BROADCAST = BalConfig(config, "bal_broadcast", True)
self.LOCKTIME_TIME = BalConfig(config, "bal_locktime_time", 90) 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.LOCKTIME_BLOCKS = BalConfig(config, "bal_locktime_blocks", 144 * 90)
self.LOCKTIMEDELTA_TIME = BalConfig(config, "bal_locktimedelta_time", 7) 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( self.LOCKTIMEDELTA_BLOCKS = BalConfig(
config, "bal_locktimedelta_blocks", 144 * 7 config, "bal_locktimedelta_blocks", 144 * 7
) )
@@ -158,6 +167,14 @@ class BalPlugin(BasePlugin):
self.PREVIEW = BalConfig(config, "bal_preview", True) self.PREVIEW = BalConfig(config, "bal_preview", True)
self.SAVE_TXS = BalConfig(config, "bal_save_txs", 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.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", True)
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True) self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True) self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)

View File

@@ -24,7 +24,13 @@ from electrum.transaction import PartialTxOutput
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is # Bitcoin consensus rule: an nLockTime value strictly below this threshold is
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX # 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 LOCKTIME_THRESHOLD = 500000000
@@ -56,11 +62,16 @@ class Util:
def str_to_locktime(locktime): def str_to_locktime(locktime):
"""Parse a user-entered locktime string into its stored form. """Parse a user-entered locktime string into its stored form.
Relative values keep their suffix (``"30d"``, ``"1y"``, ``"144b"``); Relative values keep their suffix (``"30d"``, ``"1y"``); absolute ISO
absolute ISO dates are converted to an integer UNIX timestamp. 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: try:
if locktime[-1] in ("y", "d", "b"): if locktime[-1] in ("y", "d"):
return locktime return locktime
else: else:
return int(locktime) return int(locktime)
@@ -78,8 +89,12 @@ class Util:
* plain int / timestamp -> returned unchanged * plain int / timestamp -> returned unchanged
* ``"<n>y"`` -> n years from now (as a timestamp) * ``"<n>y"`` -> n years from now (as a timestamp)
* ``"<n>d"`` -> n days from now (as a timestamp) * ``"<n>d"`` -> n days from now (as a timestamp)
* ``"<n>b"`` -> current block height + n (needs wallet
``w`` to read the chain height) Note: the legacy block-height form ``"<n>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: try:
return int(locktime) return int(locktime)
@@ -96,26 +111,23 @@ class Util:
.replace(hour=0, minute=0, second=0, microsecond=0) .replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp() .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) return int(locktime)
except Exception: except Exception:
pass pass
return 0 return 0
@staticmethod @staticmethod
def int_locktime(seconds=0, minutes=0, hours=0, days=0, blocks=0): def int_locktime(seconds=0, minutes=0, hours=0, days=0):
"""Convert a human duration into seconds (blocks counted as 600s each).""" """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( return int(
seconds seconds
+ minutes * 60 + minutes * 60
+ hours * 60 * 60 + hours * 60 * 60
+ days * 60 * 60 * 24 + days * 60 * 60 * 24
+ blocks * 600
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -337,43 +349,35 @@ class Util:
# Locktime arithmetic # Locktime arithmetic
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@staticmethod @staticmethod
def chk_locktime(timestamp_to_check, block_height_to_check, locktime): def chk_locktime(timestamp_to_check, locktime):
"""Return True if ``locktime`` is still in the future. """Return True if ``locktime`` (a UNIX timestamp) is still in the future.
Timestamp-style and block-height-style locktimes are compared against Only timestamp-based locktimes are supported now; the previous
the respective "to_check" reference value. block-height branch was removed together with block-height support.
""" """
# TODO BUG: WHAT HAPPEN AT THRESHOLD?
locktime = int(locktime) locktime = int(locktime)
if locktime > LOCKTIME_THRESHOLD and locktime > timestamp_to_check: return locktime > int(timestamp_to_check)
return True
elif locktime < LOCKTIME_THRESHOLD and locktime > block_height_to_check:
return True
else:
return False
@staticmethod @staticmethod
def anticipate_locktime(locktime, blocks=0, hours=0, days=0): def anticipate_locktime(locktime, hours=0, days=0):
"""Move a locktime earlier by the given amount. """Move a timestamp locktime earlier by the given amount.
Works on both timestamp and block-height locktimes; never returns a Every locktime is a UNIX timestamp now, so this simply subtracts the
value below 1. 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) locktime = int(locktime)
out = 0 seconds = hours * 3600 + days * 86400
if locktime > LOCKTIME_THRESHOLD: # On Windows datetime.fromtimestamp raises OverflowError past 2038
seconds = blocks * 600 + hours * 3600 + days * 86400 # (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
# On Windows datetime.fromtimestamp raises OverflowError past 2038 try:
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170). dt = datetime.fromtimestamp(locktime)
try: except (OverflowError, OSError, ValueError):
dt = datetime.fromtimestamp(locktime) dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
except (OverflowError, OSError, ValueError): dt -= timedelta(seconds=seconds)
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1)) out = dt.timestamp()
dt -= timedelta(seconds=seconds)
out = dt.timestamp()
else:
blocks -= hours * 6 + days * 144
out = locktime + blocks
if out < 1: if out < 1:
out = 1 out = 1

View File

@@ -28,7 +28,6 @@ The status flags themselves (the source of truth) stay here; only the mapping
import copy import copy
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import Logger, get_logger from electrum.logging import Logger, get_logger
from electrum.transaction import ( from electrum.transaction import (
@@ -458,7 +457,7 @@ class Will:
if ( if (
wi.get_status("VALID") wi.get_status("VALID")
or wi.get_status("CONFIRMED") or wi.get_status("CONFIRMED")
or wi.get_status("PENDING") or wi.get_status("MEMPOOL")
): ):
prevout_id = w[2].prevout.txid.hex() prevout_id = w[2].prevout.txid.hex()
if not inutxo: if not inutxo:
@@ -506,7 +505,7 @@ class Will:
if ( if (
not w.father not w.father
or willtree[w.father].get_status("CONFIRMED") 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(): for inp in w.tx.inputs():
inp_str = Util.utxo_to_str(inp) inp_str = Util.utxo_to_str(inp)
@@ -516,7 +515,7 @@ class Will:
if height < 0: if height < 0:
Will.set_invalidate(wid, willtree) Will.set_invalidate(wid, willtree)
elif height == 0: elif height == 0:
w.set_status("PENDING", True) w.set_status("MEMPOOL", True)
else: else:
w.set_status("CONFIRMED", True) w.set_status("CONFIRMED", True)
@@ -558,7 +557,20 @@ class Will:
) )
@staticmethod @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) Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos) 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 = Will.get_all_inputs(will, only_valid=True)
all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs) all_inputs_min_locktime = Will.get_all_inputs_min_locktime(all_inputs)
Will.check_will_expired( Will.check_will_expired(all_inputs_min_locktime, timestamp_to_check)
all_inputs_min_locktime, block_to_check, timestamp_to_check
)
all_inputs = Will.get_all_inputs(will, only_valid=True) all_inputs = Will.get_all_inputs(will, only_valid=True)
@@ -583,7 +593,6 @@ class Will:
@staticmethod @staticmethod
def is_will_valid( def is_will_valid(
will, will,
block_to_check,
timestamp_to_check, timestamp_to_check,
tx_fees, tx_fees,
all_utxos, all_utxos,
@@ -593,10 +602,29 @@ class Will:
wallet=False, wallet=False,
callback_not_valid_tx=None, 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 {} heirs = heirs if heirs is not None else {}
willexecutors= willexecutors if willexecutors 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 heirs:
if not Will.check_willexecutors_and_heirs( if not Will.check_willexecutors_and_heirs(
will, will,
@@ -625,25 +653,30 @@ class Will:
return True return True
@staticmethod @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") _logger.info("check if some transaction is expired")
for prevout_str, wid in all_inputs_min_locktime.items(): for prevout_str, wid in all_inputs_min_locktime.items():
for w in wid: for w in wid:
if w[1].get_status("VALID"): if w[1].get_status("VALID"):
locktime = int(wid[0][1].tx.locktime) locktime = int(wid[0][1].tx.locktime)
if locktime <= NLOCKTIME_BLOCKHEIGHT_MAX: # Locktimes are always timestamps: expired when in the past.
if locktime < int(block_to_check): if locktime < int(timestamp_to_check):
raise WillExpiredException( raise WillExpiredException(
f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}" f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}"
) )
else: else:
if locktime < int(timestamp_to_check): from datetime import datetime
raise WillExpiredException( _logger.debug(f"Will Not Expired {wid[0][0]}: {datetime.fromtimestamp(locktime).isoformat()} > {datetime.fromtimestamp(timestamp_to_check).isoformat()}")
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()}")
# def check_all_input_spent_are_in_wallet(): # def check_all_input_spent_are_in_wallet():
# _logger.info("check all input spent are in wallet or valid txs") # _logger.info("check all input spent are in wallet or valid txs")
@@ -723,11 +756,24 @@ class Will:
f"{tx_locktime}->{new_locktime} " f"{tx_locktime}->{new_locktime} "
f"on a signed/sent will" f"on a signed/sent will"
) )
# new_locktime < tx_locktime (anticipate) is left to # ANTICIPATE (new_locktime < tx_locktime): the user
# check_will_expired -> WillExpiredException. # 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 # new_locktime > tx_locktime on a will that was never
# signed/sent falls through here -> a plain rebuild via # signed/sent also falls through here -> a plain rebuild
# HeirNotFoundException (no on-chain fee needed). # via HeirNotFoundException (no on-chain fee needed).
else: else:
# The will still carries this heir, but the heir is no # The will still carries this heir, but the heir is no
# longer present in the current heirs set: the user # longer present in the current heirs set: the user
@@ -774,6 +820,18 @@ class Will:
class WillItem(Logger): 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 = { STATUS_DEFAULT = {
"ANTICIPATED": ["Anticipated", False], "ANTICIPATED": ["Anticipated", False],
"BROADCASTED": ["Broadcasted", False], "BROADCASTED": ["Broadcasted", False],
@@ -786,30 +844,57 @@ class WillItem(Logger):
"EXPORTED": ["Exported", False], "EXPORTED": ["Exported", False],
"IMPORTED": ["Imported", False], "IMPORTED": ["Imported", False],
"INVALIDATED": ["Invalidated", False], "INVALIDATED": ["Invalidated", False],
"PENDING": ["Pending", False], "MEMPOOL": ["Mempool", False],
"PUSH_FAIL": ["Push failed", False], "PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False], "PUSHED": ["Pushed", False],
"REPLACED": ["Replaced", False], "REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False], "RESTORED": ["Restored", False],
"UPDATED": ["Updated", False],
"VALID": ["Valid", True], "VALID": ["Valid", True],
} }
def set_status(self, status, value=True): def set_status(self, status, value=True):
# _logger.trace( """Set a status flag and apply the related side effects.
# "set status {} - {} {} -> {}".format(
# self._id, status, self.STATUS[status][1], value 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): if self.STATUS[status][1] == bool(value):
return None return None
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0])) self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
self.STATUS[status][1] = bool(value) self.STATUS[status][1] = bool(value)
if 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 self.STATUS["VALID"][1] = False
if status in ["CONFIRMED", "PENDING"]: if status in ["CONFIRMED", "MEMPOOL"]:
self.STATUS["INVALIDATED"][1] = False self.STATUS["INVALIDATED"][1] = False
if status in ["PUSHED"]: if status in ["PUSHED"]:
@@ -845,6 +930,14 @@ class WillItem(Logger):
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
for s in self.STATUS: for s in self.STATUS:
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1]) 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: if not _id:
self._id = self.tx.txid() self._id = self.tx.txid()
else: else:

View File

@@ -720,10 +720,18 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_invalidating(self.msg_error(e)) self.msg_set_invalidating(self.msg_error(e))
def loop_push(self): 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: if self._stopping:
return return
self.msg_set_pushing(_("Broadcasting")) self.msg_set_pushing(_("Broadcasting"))
retry = False
try: try:
willexecutors = Willexecutors.get_willexecutor_transactions( willexecutors = Willexecutors.get_willexecutor_transactions(
@@ -744,7 +752,6 @@ class BalBuildWillDialog(BalDialog):
# them sequentially after the parallel push, keeping the original # them sequentially after the parallel push, keeping the original
# check logic untouched. # check logic untouched.
already_present = [] already_present = []
retry_flag = {"value": False}
total = len(selected) total = len(selected)
done = {"count": 0} done = {"count": 0}
@@ -770,9 +777,10 @@ class BalBuildWillDialog(BalDialog):
for wid in willexecutor["txsids"]: for wid in willexecutor["txsids"]:
self.bal_window.willitems[wid].set_status("PUSHED", True) self.bal_window.willitems[wid].set_status("PUSHED", True)
else: else:
# One-shot: mark the failed transactions and move on. They
# are left as PUSH_FAIL (no automatic retry).
for wid in willexecutor["txsids"]: for wid in willexecutor["txsids"]:
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
retry_flag["value"] = True
done["count"] += 1 done["count"] += 1
# Show the per-server result (Ok/Ko) in bold + color so the # Show the per-server result (Ok/Ko) in bold + color so the
# outcome stands out, keeping the server URL in normal weight. # outcome stands out, keeping the server URL in normal weight.
@@ -781,11 +789,11 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_pushing(_status_line()) self.msg_set_pushing(_status_line())
def on_timeout(url, willexecutor): def on_timeout(url, willexecutor):
# The global deadline elapsed before this server answered. Mark # The global deadline elapsed before this server answered. Mark
# its txs as failed (so the user can retry later) and show it. # 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", []): for wid in willexecutor.get("txsids", []):
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True) self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
retry_flag["value"] = True
self.msg_edit_row( self.msg_edit_row(
"{} : {}".format(url, self.msg_error(_("Timeout - no answer"))) "{} : {}".format(url, self.msg_error(_("Timeout - no answer")))
) )
@@ -820,7 +828,6 @@ class BalBuildWillDialog(BalDialog):
"{}/{} ({}s)".format(done["count"], total, "{}/{} ({}s)".format(done["count"], total,
int(time.time() - push_start)) int(time.time() - push_start))
) )
retry = retry_flag["value"]
# Verify the "already present" servers (sequential, original logic). # Verify the "already present" servers (sequential, original logic).
self.bal_plugin = self.bal_window.bal_plugin self.bal_plugin = self.bal_window.bal_plugin
@@ -851,15 +858,18 @@ class BalBuildWillDialog(BalDialog):
row, row,
) )
if retry: # One-shot broadcast: we deliberately do NOT raise/retry when some
raise Exception("retry") # 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: 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.msg_set_pushing(self.msg_error(e))
self.wait(10) self.wait(10)
if not self._stopping: if not self._stopping:
pass pass
# self.loop_push()
def invalidate_task(self, password, bal_window, tx): def invalidate_task(self, password, bal_window, tx):
if self._stopping: if self._stopping:
@@ -1038,6 +1048,18 @@ class BalBuildWillDialog(BalDialog):
short to be sure the user noticed the in-dialog line). short to be sure the user noticed the in-dialog line).
""" """
self._next_steps_hint = None 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: try:
need_sign = False need_sign = False
need_push = False need_push = False

View File

@@ -551,6 +551,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if will: if will:
self.bal_window.check_transactions(will) self.bal_window.check_transactions(will)
self.update() 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): def invalidate_will(self):
self.bal_window.invalidate_will() self.bal_window.invalidate_will()

View File

@@ -389,6 +389,13 @@ class Plugin(BalPlugin):
heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change) heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change)
heir_hide_invalidated = BalCheckBox(self.HIDE_INVALIDATED, 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 = QPushButton("Rebroadcast transactions")
heir_repush.clicked.connect(partial(self.broadcast_transactions, True)) heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
bal_mode = QComboBox() bal_mode = QComboBox()
@@ -410,18 +417,30 @@ class Plugin(BalPlugin):
2, 2,
"Hide invalidated transactions from will detail and list", "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( add_widget(
grid, grid,
"Calendar App", "Calendar App",
BalLineEdit(self.CALENDAR_APP), BalLineEdit(self.CALENDAR_APP),
3, 4,
"Default app used to open calendar", "Default app used to open calendar",
) )
add_widget( add_widget(
grid, grid,
"Event summary", "Event summary",
BalLineEdit(self.EVENT_SUMMARY), BalLineEdit(self.EVENT_SUMMARY),
4, 5,
( (
"Default message to be used in event summary\n" "Default message to be used in event summary\n"
"Variables:\n" "Variables:\n"
@@ -432,9 +451,9 @@ class Plugin(BalPlugin):
) )
add_widget( add_widget(
grid, grid,
"Event sescription", "Event description",
BalTextEdit(self.EVENT_DESCRIPTION), BalTextEdit(self.EVENT_DESCRIPTION),
5, 6,
( (
"Default message to be used in event description\n" "Default message to be used in event description\n"
"Variables:\n" "Variables:\n"

View File

@@ -22,8 +22,9 @@ status into a colour for the transaction list / detail views.
_STATUS_COLOR_PRIORITY = ( _STATUS_COLOR_PRIORITY = (
("INVALIDATED", "#f87838"), # orange - tx can no longer be mined ("INVALIDATED", "#f87838"), # orange - tx can no longer be mined
("REPLACED", "#ff97e9"), # pink - superseded by another tx ("REPLACED", "#ff97e9"), # pink - superseded by another tx
("UPDATED", "#b266b2"), # light violet - replaced keeping same locktime+heirs
("CONFIRMED", "#bfbfbf"), # grey - already mined ("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. # Default colour used when no status in the priority list matches.

View File

@@ -347,12 +347,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.textChanged.connect(self.numbify) self.textChanged.connect(self.numbify)
self.isdays = False self.isdays = False
self.isyears = False self.isyears = False
self.isblocks = False
self.time_edit = time_edit self.time_edit = time_edit
@staticmethod @staticmethod
def replace_str(text): 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): def checkbdy(self, s, pos, appendix):
try: try:
@@ -367,33 +371,29 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
return pos, s return pos, s
def numbify(self): 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() text = self.text().strip()
# chars = '0123456789bdy' removed the option to choose locktime by block
chars = "0123456789dy" chars = "0123456789dy"
pos = self.cursorPosition() pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars])) pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars]) s = "".join([i for i in text if i in chars])
self.isdays = False self.isdays = False
self.isyears = False self.isyears = False
self.isblocks = False
pos, s = self.checkbdy(s, pos, "d") pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y") pos, s = self.checkbdy(s, pos, "y")
pos, s = self.checkbdy(s, pos, "b")
if "d" in s: if "d" in s:
self.isdays = True self.isdays = True
if "y" in s: if "y" in s:
self.isyears = True self.isyears = True
if "b" in s:
self.isblocks = True
if self.isdays: if self.isdays:
s = self.replace_str(s) + "d" s = self.replace_str(s) + "d"
if self.isyears: if self.isyears:
s = self.replace_str(s) + "y" s = self.replace_str(s) + "y"
if self.isblocks:
s = self.replace_str(s) + "b"
self.blockSignals(True) self.blockSignals(True)
self.setText(s) self.setText(s)
self.blockSignals(False) self.blockSignals(False)
@@ -420,6 +420,11 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
class LockTimeDateEdit(QDateTimeEdit, _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 min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1
max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp() max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp()

View File

@@ -368,7 +368,6 @@ class BalWindow:
def check_will(self): def check_will(self):
return Will.is_will_valid( return Will.is_will_valid(
self.willitems, self.willitems,
self.block_to_check,
self.date_to_check, self.date_to_check,
self.will_settings["baltx_fees"], self.will_settings["baltx_fees"],
self.window.wallet.get_utxos(), self.window.wallet.get_utxos(),
@@ -459,9 +458,10 @@ class BalWindow:
try: try:
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
# found = False # found = False
self.locktime_blocks = self.bal_plugin.LOCKTIME_BLOCKS.get() # NOTE: block-height tracking removed (A1) - locktimes are always
self.current_block = Util.get_current_height(self.wallet.network) # UNIX timestamps now, so we no longer read the current block height
self.block_to_check = 0 # 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.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
self.willexecutors = Willexecutors.get_willexecutors( self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=True, bal_window=self, task=False 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): def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
try: try:
_logger.info(
"BAL-plugin \u25b8 STEP 1/7: prepare inheritance "
"(validate settings, amounts and locktime)"
)
if self.disable_plugin: if self.disable_plugin:
_logger.info("plugin is disabled") _logger.info("plugin is disabled")
return return
@@ -523,11 +527,26 @@ class BalWindow:
return return
try: 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() self.check_will()
_logger.info(
"BAL-plugin \u25b8 STEP 2/7 result: will is COHERENT, "
"nothing to rebuild"
)
except WillExpiredException: except WillExpiredException:
_logger.info(
"BAL-plugin \u25b8 STEP 2/7 result: will EXPIRED -> "
"invalidating on-chain (real fee)"
)
self.invalidate_will() self.invalidate_will()
return return
except NoHeirsException: except NoHeirsException:
_logger.info(
"BAL-plugin \u25b8 STEP 2/7 result: no valid heirs -> abort"
)
return return
except WillPostponedException as e: except WillPostponedException as e:
# The will was already signed/sent and is being postponed. # 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" # can never be used by a will-executor), then press "Prepare"
# again # again
# to create the new postponed inheritance. # 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}") _logger.info(f"will postponed: {e}")
self.show_message( self.show_message(
_( _(
@@ -553,6 +576,10 @@ class BalWindow:
self.invalidate_will() self.invalidate_will()
return return
except NotCompleteWillException as e: 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)) _logger.info("{}:{}".format(type(e), e))
message = False message = False
if isinstance(e, HeirChangeException): if isinstance(e, HeirChangeException):

View File

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

View File

@@ -23,7 +23,7 @@
<!-- Title --> <!-- Title -->
<text x="24" y="34" font-size="20" font-weight="700">BAL — Inheritance change decision flow</text> <text x="24" y="34" font-size="20" font-weight="700">BAL — Inheritance change decision flow</text>
<text x="24" y="56" class="sub">What happens to your pre-signed transactions when you change the will (v0.3.3)</text> <text x="24" y="56" class="sub">What happens to your pre-signed transactions when you change the will (v0.3.4)</text>
<!-- Legend --> <!-- Legend -->
<g transform="translate(760,76)"> <g transform="translate(760,76)">
@@ -63,32 +63,32 @@
<path class="edge" d="M420,256 L300,256"/> <path class="edge" d="M420,256 L300,256"/>
<text x="330" y="250" class="lbl">No</text> <text x="330" y="250" class="lbl">No</text>
<!-- D earlier? --> <!-- D new date in the past? -->
<polygon class="dec" points="590,312 800,350 590,388 380,350"/> <polygon class="dec" points="590,312 800,350 590,388 380,350"/>
<text x="590" y="346" text-anchor="middle">VALID tx with locktime</text> <text x="590" y="346" text-anchor="middle">New delivery date</text>
<text x="590" y="362" text-anchor="middle" class="sub">earlier than the new date? (anticipate)</text> <text x="590" y="362" text-anchor="middle" class="sub">already in the PAST?</text>
<path class="edge" d="M590,290 L590,312"/> <path class="edge" d="M590,290 L590,312"/>
<!-- D Yes -> E --> <!-- D No -> AN anticipate? -->
<polygon class="dec" points="930,312 1110,350 930,388 750,350"/> <polygon class="dec" points="930,312 1110,350 930,388 750,350"/>
<text x="930" y="346" text-anchor="middle">Already signed</text> <text x="930" y="346" text-anchor="middle">Moved the date</text>
<text x="930" y="362" text-anchor="middle" class="sub">or sent?</text> <text x="930" y="362" text-anchor="middle" class="sub">EARLIER? (anticipate)</text>
<path class="edge" d="M800,350 L750,350"/> <path class="edge" d="M800,350 L750,350"/>
<text x="772" y="344" class="lbl">Yes (earlier)</text> <text x="772" y="344" class="lbl">No (still future)</text>
<!-- E No -> R1 rebuild --> <!-- AN Yes -> R1 rebuild (never invalidates) -->
<rect class="rebuild" x="820" y="410" width="220" height="48" rx="8"/> <rect class="rebuild" x="820" y="410" width="220" height="48" rx="8"/>
<text x="930" y="430" text-anchor="middle" class="ttl">Rebuild only</text> <text x="930" y="428" text-anchor="middle" class="ttl">Rebuild only</text>
<text x="930" y="446" text-anchor="middle" class="sub">no on-chain cost</text> <text x="930" y="444" text-anchor="middle" class="sub">no on-chain cost - NEVER invalidates</text>
<path class="edge" d="M930,388 L930,410"/> <path class="edge" d="M930,388 L930,410"/>
<text x="940" y="402" class="lbl">Not signed</text> <text x="940" y="402" class="lbl">Yes (signed or not)</text>
<!-- E Yes -> INV2 --> <!-- D Yes (past) -> INV2 -->
<rect class="inval" x="820" y="476" width="240" height="48" rx="8"/> <rect class="inval" x="820" y="476" width="240" height="48" rx="8"/>
<text x="940" y="496" text-anchor="middle" class="ttl">Invalidate on-chain FIRST</text> <text x="940" y="496" text-anchor="middle" class="ttl">Invalidate on-chain FIRST</text>
<text x="940" y="512" text-anchor="middle" class="sub">WillExpired</text> <text x="940" y="512" text-anchor="middle" class="sub">WillExpired (date in the past)</text>
<path class="edge" d="M1010,386 C1090,430 1080,460 1010,476"/> <path class="edge" d="M800,350 C1010,355 980,440 980,476"/>
<text x="1058" y="430" class="lbl">Signed</text> <text x="806" y="372" class="lbl">Yes (past)</text>
<!-- D No -> F --> <!-- D No -> F -->
<polygon class="dec" points="430,420 620,458 430,496 240,458"/> <polygon class="dec" points="430,420 620,458 430,496 240,458"/>

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -38,6 +38,12 @@
.pill.blue{background:var(--blue)} .pill.blue{background:var(--blue)}
.pill.green{background:var(--green);color:#fff} .pill.green{background:var(--green);color:#fff}
.pill.grey{background:var(--grey);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} .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} .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} 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.</strong></blockquote>
<table> <table>
<thead><tr><th>Status</th><th>Meaning</th><th>Set when</th></tr></thead> <thead><tr><th>Status</th><th>Meaning</th><th>Set when</th></tr></thead>
<tbody> <tbody>
<tr><td><code>VALID</code></td><td>The current, usable plan</td><td>default; cleared by INVALIDATED/REPLACED/CONFIRMED/PENDING</td></tr> <tr><td><code>VALID</code></td><td>The current, usable plan</td><td>default; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL</td></tr>
<tr><td><code>COMPLETE</code> (<em>Signed</em>)</td><td>The transaction has been <strong>signed</strong></td><td>after <strong>Sign</strong></td></tr> <tr><td><code>COMPLETE</code> (<em>Signed</em>)</td><td>The transaction has been <strong>signed</strong></td><td>after <strong>Sign</strong></td></tr>
<tr><td><code>PUSHED</code></td><td>Signed tx <strong>sent to executor(s)</strong></td><td>after <strong>Broadcast</strong> to executors</td></tr> <tr><td><code>PUSHED</code></td><td>Signed tx <strong>sent to executor(s)</strong></td><td>after <strong>Broadcast</strong> to executors</td></tr>
<tr><td><code>CHECKED</code></td><td>Executor <strong>confirmed</strong> it holds the tx</td><td>after a successful server <strong>Check</strong> (implies PUSHED)</td></tr> <tr><td><code>CHECKED</code></td><td>Executor <strong>confirmed</strong> it holds the tx</td><td>after a successful server <strong>Check</strong> (implies PUSHED)</td></tr>
<tr><td><code>CHECK_FAIL</code></td><td>Server <strong>check failed</strong></td><td>a queried executor did not return the tx</td></tr> <tr><td><code>CHECK_FAIL</code></td><td>Server <strong>check failed</strong></td><td>a queried executor did not return the tx</td></tr>
<tr><td><code>PUSH_FAIL</code></td><td>Sending to the executor failed</td><td>cleared when PUSHED becomes true</td></tr> <tr><td><code>PUSH_FAIL</code></td><td>Sending to the executor failed</td><td>cleared when PUSHED becomes true</td></tr>
<tr><td><code>CONFIRMED</code></td><td>Tx <strong>mined onchain</strong></td><td>seen onchain, height &gt; 0</td></tr> <tr><td><code>CONFIRMED</code></td><td>Tx <strong>mined onchain</strong></td><td>seen onchain, height &gt; 0</td></tr>
<tr><td><code>PENDING</code></td><td>Tx <strong>in the mempool</strong></td><td>seen onchain, height 0</td></tr> <tr><td><code>MEMPOOL</code></td><td>Tx <strong>in the Electrum mempool</strong></td><td>seen onchain, height 0 (named <code>PENDING</code> before v0.3.4)</td></tr>
<tr><td><code>INVALIDATED</code></td><td>Inputs spent → can never confirm</td><td>invalidation tx / inputs gone</td></tr> <tr><td><code>INVALIDATED</code></td><td>Inputs spent → can never confirm</td><td>invalidation tx / inputs gone</td></tr>
<tr><td><code>REPLACED</code></td><td>Superseded by an earlierlocktime child</td><td>a replacing child found</td></tr> <tr><td><code>REPLACED</code></td><td>Superseded by an <strong>earlier</strong>locktime child</td><td>a replacing child found</td></tr>
<tr><td><code>ANTICIPATED</code></td><td>Locktime anticipated by 1 day vs a preexisting tx with the same heirs</td><td><code>set_anticipate</code> (tx <strong>stays</strong> VALID)</td></tr>
<tr><td><code>UPDATED</code></td><td>Replaced by a new tx keeping the <strong>same</strong> locktime + same heirs</td><td>samelocktime replacement (tx <strong>stays</strong> VALID)</td></tr>
<tr><td><code>EXPIRED</code></td><td>Locktime already in the past vs the check date</td><td><code>check_will_expired</code></td></tr> <tr><td><code>EXPIRED</code></td><td>Locktime already in the past vs the check date</td><td><code>check_will_expired</code></td></tr>
</tbody> </tbody>
</table> </table>
<h3>Safety rules baked into <code>set_status</code></h3> <h3>Safety rules baked into <code>set_status</code></h3>
<ul> <ul>
<li>Setting <code>INVALIDATED</code> / <code>REPLACED</code> / <code>CONFIRMED</code> / <code>PENDING</code> → clears <code>VALID</code>.</li> <li>Setting <code>INVALIDATED</code> / <code>REPLACED</code> / <code>CONFIRMED</code> / <code>MEMPOOL</code> → clears <code>VALID</code>.</li>
<li>Setting <code>CONFIRMED</code> / <code>PENDING</code> → clears <code>INVALIDATED</code>.</li> <li>Setting <code>ANTICIPATED</code> <strong>keeps</strong> <code>VALID</code> (only moves the locktime 1 day earlier).</li>
<li>Setting <code>UPDATED</code><strong>keeps</strong> <code>VALID</code> (same locktime + same heirs).</li>
<li>Setting <code>CONFIRMED</code> / <code>MEMPOOL</code> → clears <code>INVALIDATED</code>.</li>
<li>Setting <code>PUSHED</code> → clears <code>PUSH_FAIL</code> <strong>and</strong> <code>CHECK_FAIL</code>.</li> <li>Setting <code>PUSHED</code> → clears <code>PUSH_FAIL</code> <strong>and</strong> <code>CHECK_FAIL</code>.</li>
<li>Setting <code>CHECKED</code> → implies <code>PUSHED</code>.</li> <li>Setting <code>CHECKED</code> → implies <code>PUSHED</code>.</li>
</ul> </ul>
<h3>How states map to row colour</h3> <h3>How states map to row colour</h3>
<table> <table>
<thead><tr><th>State (first match wins)</th><th>Colour</th><th>Hex</th></tr></thead> <thead><tr><th>Priority</th><th>State</th><th>Colour</th><th>Hex</th></tr></thead>
<tbody> <tbody>
<tr><td><code>CHECK_FAIL</code></td><td><span class="pill red">red</span></td><td><code>#e83845</code></td></tr> <tr><td>1</td><td><code>INVALIDATED</code></td><td><span class="pill orange">orange</span></td><td><code>#f87838</code></td></tr>
<tr><td><code>INVALIDATED</code> / <code>REPLACED</code></td><td><span class="pill grey">grey</span></td><td>muted</td></tr> <tr><td>2</td><td><code>REPLACED</code></td><td><span class="pill pink">pink</span></td><td><code>#ff97e9</code></td></tr>
<tr><td><code>CONFIRMED</code></td><td><span class="pill green">green</span></td><td>confirmed</td></tr> <tr><td>3</td><td><code>UPDATED</code></td><td><span class="pill violet">light violet</span></td><td><code>#b266b2</code></td></tr>
<tr><td><code>COMPLETE</code> (signed, not pushed)</td><td><span class="pill blue">blue</span></td><td><code>#2bc8ed</code></td></tr> <tr><td>4</td><td><code>CONFIRMED</code></td><td><span class="pill grey">grey</span></td><td><code>#bfbfbf</code></td></tr>
<tr><td><code>VALID</code> (prepared, not signed)</td><td>default</td><td></td></tr> <tr><td>5</td><td><code>MEMPOOL</code></td><td><span class="pill yellow">yellow</span></td><td><code>#ffce30</code></td></tr>
<tr><td>6</td><td><code>CHECK_FAIL</code> (and not <code>CHECKED</code>)</td><td><span class="pill red">red</span></td><td><code>#e83845</code></td></tr>
<tr><td>7</td><td><code>CHECKED</code></td><td><span class="pill green">green</span></td><td><code>#8afa6c</code></td></tr>
<tr><td>8</td><td><code>PUSH_FAIL</code></td><td><span class="pill red">red</span></td><td><code>#e83845</code></td></tr>
<tr><td>9</td><td><code>PUSHED</code></td><td><span class="pill teal">teal</span></td><td><code>#73f3c8</code></td></tr>
<tr><td>10</td><td><code>COMPLETE</code> (signed, not pushed)</td><td><span class="pill blue">blue</span></td><td><code>#2bc8ed</code></td></tr>
<tr><td></td><td>none of the above (plain <code>VALID</code>)</td><td>default white</td><td><code>#ffffff</code></td></tr>
</tbody> </tbody>
</table> </table>
<blockquote><strong>v0.3.3 fix:</strong> a will that is <em>signed but not yet broadcast</em> <blockquote><strong>v0.3.3 fix:</strong> a will that is <em>signed but not yet broadcast</em>
@@ -125,13 +141,13 @@ flowchart TD
B -- No --> Z1[/Show: Heirs are not defined — stop/] B -- No --> Z1[/Show: Heirs are not defined — stop/]
B -- Yes --> C{Check-Alive threshold in the future?} B -- Yes --> C{Check-Alive threshold in the future?}
C -- "No, it's in the past" --> INV1[[Invalidate on-chain<br/>CheckAliveError]] C -- "No, it's in the past" --> INV1[[Invalidate on-chain<br/>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?} D -- "Yes date is now expired" --> INV2[[Invalidate on-chain FIRST<br/>WillExpired]]
E -- "Not signed yet" --> R1[[Rebuild only<br/>no on-chain cost]] D -- "No date still in the future" --> AN{Did you move the date EARLIER anticipate?}
E -- "Signed / sent" --> INV2[[Invalidate on-chain FIRST<br/>WillExpired]]
D -- No --> F{Will-executor / fee / heirs unchanged?} AN -- "Yes anticipate — signed or not" --> R1[[Rebuild only<br/>no on-chain cost<br/>NEVER invalidates]]
AN -- No --> F{Will-executor / fee / heirs unchanged?}
F -- "Fee changed" --> R2[[Rebuild<br/>TxFeesChanged]] F -- "Fee changed" --> R2[[Rebuild<br/>TxFeesChanged]]
F -- "Will-executor changed/absent" --> R3[[Rebuild<br/>WillExecutorNotPresent / Change]] F -- "Will-executor changed/absent" --> R3[[Rebuild<br/>WillExecutorNotPresent / Change]]
F -- "Heir added/removed, % or address changed" --> G{POSTPONE of an already signed/sent tx?} F -- "Heir added/removed, % or address changed" --> G{POSTPONE of an already signed/sent tx?}
@@ -165,13 +181,20 @@ alreadysigned transaction</strong> (<code>w.tx.locktime</code>) — exactly w
<tbody> <tbody>
<tr><td>Move date <strong>LATER</strong> (postpone)</td><td>No (never signed)</td><td>Plain <strong>rebuild</strong></td><td class="no">No</td></tr> <tr><td>Move date <strong>LATER</strong> (postpone)</td><td>No (never signed)</td><td>Plain <strong>rebuild</strong></td><td class="no">No</td></tr>
<tr><td>Move date <strong>LATER</strong> (postpone)</td><td>Yes</td><td><strong>Invalidate first</strong>, then rebuild (WillPostponed)</td><td class="fee">Yes</td></tr> <tr><td>Move date <strong>LATER</strong> (postpone)</td><td>Yes</td><td><strong>Invalidate first</strong>, then rebuild (WillPostponed)</td><td class="fee">Yes</td></tr>
<tr><td>Move date <strong>EARLIER</strong> (anticipate)</td><td>any</td><td>Old tx <strong>expired</strong> invalidate (WillExpired)</td><td class="fee">Yes</td></tr> <tr><td>Move date <strong>EARLIER, still in the future</strong> (anticipate)</td><td><strong>any</strong> (signed or not)</td><td>Plain <strong>rebuild</strong> with the new earlier locktime — <strong>never</strong> invalidates</td><td class="no">No</td></tr>
<tr><td>Move date <strong>EARLIER into the past</strong> (new date already passed)</td><td></td><td>Will is genuinely <strong>expired</strong> → invalidate (WillExpired)</td><td class="fee">Yes</td></tr>
<tr><td>CheckAlive threshold already passed</td><td></td><td><strong>Invalidate</strong> (CheckAliveError)</td><td class="fee">Yes</td></tr> <tr><td>CheckAlive threshold already passed</td><td></td><td><strong>Invalidate</strong> (CheckAliveError)</td><td class="fee">Yes</td></tr>
</tbody> </tbody>
</table> </table>
<blockquote><strong>Why postpone needs onchain invalidation:</strong> the executor still holds the <em>old</em> <blockquote><strong>Why postpone needs onchain invalidation:</strong> the executor still holds the <em>old</em>
transaction with the <em>earlier</em> locktime. Spending its inputs onchain makes it unminable, so it can transaction with the <em>earlier</em> locktime. Spending its inputs onchain makes it unminable, so it can
never execute the inheritance early. The plugin tells you this and offers to build the invalidation tx.</blockquote> never execute the inheritance early. The plugin tells you this and offers to build the invalidation tx.</blockquote>
<blockquote><strong>Why anticipate (move earlier, still future) is NOT onchain:</strong> moving the delivery
date <em>earlier</em> only makes the inheritance available <em>sooner</em>; there is no earlyexecution risk,
so the plugin simply <strong>rebuilds</strong> the transactions with the new earlier locktime — <strong>no
onchain invalidation and no Bitcoin fee</strong>. This holds <strong>even if the will was already
signed/sent</strong>: anticipating never invalidates. Only a date that lands in the <em>past</em> is treated
as expired and invalidated (<code>WillExpired</code>).</blockquote>
<h3>4.2 Adding an heir</h3> <h3>4.2 Adding an heir</h3>
<p>An heir present in your set but not yet in the will raises <code>HeirNotFoundException</code>.</p> <p>An heir present in your set but not yet in the will raises <code>HeirNotFoundException</code>.</p>
@@ -237,11 +260,12 @@ executor that <em>should</em> hold your tx did not return it — reBroadcast
<tr><td>Change % / address (only prepared)</td><td class="yes">Yes</td><td class="no">No</td></tr> <tr><td>Change % / address (only prepared)</td><td class="yes">Yes</td><td class="no">No</td></tr>
<tr><td>Change fee rate</td><td class="yes">Yes</td><td class="no">No</td></tr> <tr><td>Change fee rate</td><td class="yes">Yes</td><td class="no">No</td></tr>
<tr><td>Change / remove willexecutor</td><td class="yes">Yes</td><td class="no">No</td></tr> <tr><td>Change / remove willexecutor</td><td class="yes">Yes</td><td class="no">No</td></tr>
<tr><td>Move date <strong>earlier</strong> (anticipate)</td><td class="yes">Yes after</td><td class="fee">Yes</td></tr> <tr><td>Move date <strong>earlier, still in the future</strong> (anticipate) — signed <strong>or</strong> not</td><td class="yes">Yes</td><td class="no">No</td></tr>
<tr><td>Move date <strong>earlier into the past</strong> (new date already passed)</td><td class="yes">Yes after</td><td class="fee">Yes</td></tr>
<tr><td>Move date <strong>later</strong> — will <strong>signed/sent</strong></td><td class="yes">Yes after</td><td class="fee">Yes</td></tr> <tr><td>Move date <strong>later</strong> — will <strong>signed/sent</strong></td><td class="yes">Yes after</td><td class="fee">Yes</td></tr>
<tr><td>Move date <strong>later</strong> — will <strong>only prepared</strong></td><td class="yes">Yes</td><td class="no">No</td></tr> <tr><td>Move date <strong>later</strong> — will <strong>only prepared</strong></td><td class="yes">Yes</td><td class="no">No</td></tr>
<tr><td>CheckAlive threshold already passed</td><td class="yes">Yes after</td><td class="fee">Yes</td></tr> <tr><td>CheckAlive threshold already passed</td><td class="yes">Yes after</td><td class="fee">Yes</td></tr>
<tr><td>Any change to an <strong>already signed/sent</strong> will</td><td class="yes">Yes after</td><td class="fee">Yes</td></tr> <tr><td>Any change to an <strong>already signed/sent</strong> will <strong>that postpones it or expires it</strong></td><td class="yes">Yes after</td><td class="fee">Yes</td></tr>
<tr><td>Nothing changed</td><td class="no">No</td><td class="no">No</td></tr> <tr><td>Nothing changed</td><td class="no">No</td><td class="no">No</td></tr>
</tbody> </tbody>
</table> </table>
@@ -249,13 +273,16 @@ executor that <em>should</em> hold your tx did not return it — reBroadcast
<h2>7. Golden rules</h2> <h2>7. Golden rules</h2>
<ol> <ol>
<li><strong>Before it's signed</strong>, changing anything is free — just <strong>Prepare</strong> again.</li> <li><strong>Before it's signed</strong>, changing anything is free — just <strong>Prepare</strong> again.</li>
<li><strong>After it's signed/sent</strong>, moving the date or replacing it requires an <strong>onchain <li><strong>After it's signed/sent</strong>, only <strong>postponing</strong> the date (moving it <em>later</em>),
invalidation first</strong> (a small Bitcoin fee) so an old transaction can never execute early.</li> or letting it <strong>expire</strong> (a date now in the past), requires an <strong>onchain invalidation
first</strong> (a small Bitcoin fee) so an old transaction can never execute early.
<strong>Anticipating</strong> (moving the date <em>earlier</em>, still in the future) never invalidates —
it is just a free <strong>rebuild</strong>.</li>
<li>Always finish with <strong>Sign → Broadcast → Check</strong> so executors hold the current plan (green).</li> <li>Always finish with <strong>Sign → Broadcast → Check</strong> so executors hold the current plan (green).</li>
<li>The wallet is always <strong>fully emptied</strong> by the inheritance, so heir amounts must add up.</li> <li>The wallet is always <strong>fully emptied</strong> by the inheritance, so heir amounts must add up.</li>
</ol> </ol>
<footer>This document reflects BAL plugin v0.3.3. Behaviour is derived directly from <footer>This document reflects BAL plugin v0.3.4. Behaviour is derived directly from
<code>core/will.py</code> and <code>gui/qt/window.py</code>.</footer> <code>core/will.py</code> and <code>gui/qt/window.py</code>.</footer>
</div> </div>

View File

@@ -41,34 +41,50 @@ important ones:
| Status | Meaning | Set when | | Status | Meaning | Set when |
|---|---|---| |---|---|---|
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/PENDING | | `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** | | `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
| `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors | | `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors |
| `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) | | `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx | | `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
| `PUSH_FAIL` | Sending to the executor failed | cleared when PUSHED becomes true | | `PUSH_FAIL` | Sending to the executor failed | cleared when PUSHED becomes true |
| `CONFIRMED` | The tx is **mined onchain** | seen onchain with height > 0 | | `CONFIRMED` | The tx is **mined onchain** | seen onchain with height > 0 |
| `PENDING` | The tx is **in the mempool** (height 0) | seen onchain, not yet mined | | `MEMPOOL` | The tx is **in the Electrum mempool** (height 0) | seen onchain, not yet mined (was named `PENDING` before v0.3.4) |
| `INVALIDATED` | Its inputs were spent → it can never confirm | invalidation tx created / inputs gone | | `INVALIDATED` | Its inputs were spent → it can never confirm | invalidation tx created / inputs gone |
| `REPLACED` | Superseded by a child tx with earlier locktime | a replacing child was found | | `REPLACED` | Superseded by a child tx with **earlier** locktime | a replacing child was found |
| `ANTICIPATED` | Its locktime was anticipated by 1 day vs a preexisting tx with the same heirs | `set_anticipate` (the tx **stays** `VALID`) |
| `UPDATED` | Replaced by a new tx that keeps the **same** locktime **and** same heirs | a samelocktime replacement was applied (the tx **stays** `VALID`) |
| `EXPIRED` | Its locktime is already in the past relative to the check date | `check_will_expired` | | `EXPIRED` | Its locktime is already in the past relative to the check date | `check_will_expired` |
Flag transitions enforced by `set_status` (the safety rules baked in the code): Flag transitions enforced by `set_status` (the safety rules baked in the code):
- Setting `INVALIDATED` / `REPLACED` / `CONFIRMED` / `PENDING` → clears `VALID`. - Setting `INVALIDATED` / `REPLACED` / `CONFIRMED` / `MEMPOOL` → clears `VALID`.
- Setting `CONFIRMED` / `PENDING` → clears `INVALIDATED`. - Setting `ANTICIPATED`**keeps** `VALID` (anticipating only moves the locktime
1 day earlier; the tx stays valid).
- Setting `UPDATED`**keeps** `VALID` (same locktime + same heirs; the tx stays
valid).
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`. - Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`). - Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
### How states map to row colour in the list ### How states map to row colour in the list
| State (first match wins) | Colour | Hex | The colour is decided by `status_color` (in `gui/qt/theme.py`). The list below is
|---|---|---| in the exact priority order used by the code — the **first** matching status
| `CHECK_FAIL` | red | `#e83845` | wins:
| `INVALIDATED` / `REPLACED` | grey | (muted) |
| `CONFIRMED` | green | (confirmed on server / chain) | | Priority | State | Colour | Hex |
| `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` | |---|---|---|---|
| `VALID` (prepared, not signed) | default | — | | 1 | `INVALIDATED` | orange | `#f87838` |
| 2 | `REPLACED` | pink | `#ff97e9` |
| 3 | `UPDATED` | light violet | `#b266b2` |
| 4 | `CONFIRMED` | grey | `#bfbfbf` |
| 5 | `MEMPOOL` | yellow | `#ffce30` |
| 6 | `CHECK_FAIL` (and **not** `CHECKED`) | red | `#e83845` |
| 7 | `CHECKED` | green | `#8afa6c` |
| 8 | `PUSH_FAIL` | red | `#e83845` |
| 9 | `PUSHED` | teal | `#73f3c8` |
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast* > **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
> (`COMPLETE` and **not** `PUSHED`) is **not** queried on the server, so it stays > (`COMPLETE` and **not** `PUSHED`) is **not** queried on the server, so it stays
@@ -97,13 +113,13 @@ flowchart TD
B -- No --> Z1[/Show: "Heirs are not defined" — stop/] B -- No --> Z1[/Show: "Heirs are not defined" — stop/]
B -- Yes --> C{Check-Alive threshold<br/>in the future?} B -- Yes --> C{Check-Alive threshold<br/>in the future?}
C -- No, it's in the past --> INV1[[Invalidate on-chain<br/>CheckAliveError]] C -- No, it's in the past --> INV1[[Invalidate on-chain<br/>CheckAliveError]]
C -- Yes --> D{Any VALID tx with<br/>locktime earlier than<br/>the new date?} C -- Yes --> D{New delivery date<br/>already in the PAST?}
D -- "Yes (you moved the date EARLIER / anticipate)" --> E{Was that tx already<br/>signed or sent?} D -- "Yes (date is now expired)" --> INV2[[Invalidate on-chain FIRST<br/>WillExpired]]
E -- "Not signed yet" --> R1[[Rebuild only<br/>no on-chain cost]] D -- "No (date still in the future)" --> AN{Did you move the date<br/>EARLIER (anticipate)?}
E -- "Signed / sent" --> INV2[[Invalidate on-chain FIRST<br/>WillExpired]]
D -- "No" --> F{Will-executor / fee /<br/>heirs unchanged?} AN -- "Yes (anticipate) — signed or not" --> R1[[Rebuild only<br/>no on-chain cost<br/>NEVER invalidates]]
AN -- "No" --> F{Will-executor / fee /<br/>heirs unchanged?}
F -- "Fee changed" --> R2[[Rebuild<br/>TxFeesChanged]] F -- "Fee changed" --> R2[[Rebuild<br/>TxFeesChanged]]
F -- "Will-executor changed/absent" --> R3[[Rebuild<br/>WillExecutorNotPresent / Change]] F -- "Will-executor changed/absent" --> R3[[Rebuild<br/>WillExecutorNotPresent / Change]]
F -- "Heir added or removed,<br/>% or address changed" --> G{Is it a POSTPONE of an<br/>already signed/sent tx?} F -- "Heir added or removed,<br/>% or address changed" --> G{Is it a POSTPONE of an<br/>already signed/sent tx?}
@@ -142,7 +158,8 @@ exactly what the willexecutors hold — not the inmemory copy.
|---|---|---|---| |---|---|---|---|
| **Move date LATER** (postpone) | **No** (never signed) | Plain **rebuild** (`HeirNotFound` fallthrough) | **No** | | **Move date LATER** (postpone) | **No** (never signed) | Plain **rebuild** (`HeirNotFound` fallthrough) | **No** |
| **Move date LATER** (postpone) | **Yes** | **Invalidate first**, then rebuild (`WillPostponed`) | **Yes** | | **Move date LATER** (postpone) | **Yes** | **Invalidate first**, then rebuild (`WillPostponed`) | **Yes** |
| **Move date EARLIER** (anticipate) | any | Old tx becomes **expired****invalidate** (`WillExpired`) | **Yes** | | **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fallthrough) — **never** an onchain invalidation | **No** |
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired****invalidate** (`WillExpired`) | **Yes** |
| CheckAlive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** | | CheckAlive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
> **Why postpone needs an onchain invalidation:** the willexecutor still holds > **Why postpone needs an onchain invalidation:** the willexecutor still holds
@@ -152,9 +169,24 @@ exactly what the willexecutors hold — not the inmemory copy.
> Spending the old transaction's inputs onchain makes the old tx **unminable**. > Spending the old transaction's inputs onchain makes the old tx **unminable**.
> The plugin tells you this explicitly and offers to build the invalidation tx. > The plugin tells you this explicitly and offers to build the invalidation tx.
> **Why anticipate is also onchain:** moving the date earlier makes the current > **Why anticipate (move earlier, still future) is NOT onchain:** moving the
> committed tx *expired* relative to the new check date; the safe path is the > delivery date *earlier* only makes the inheritance available *sooner*. There is
> same — invalidate, then rebuild. > no earlyexecution risk to protect against — on the contrary, the new plan is
> *more* restrictive than the old one. So the plugin simply **rebuilds** the
> transactions with the new, earlier locktime; **no onchain invalidation and no
> Bitcoin fee** are needed. This holds **even if the will was already
> signed/sent**: anticipating never invalidates.
>
> This is the opposite of postpone: postpone (later date) is dangerous because
> the willexecutor could still broadcast the *earlier* old tx; anticipate
> (earlier date) is safe because the old, *later* tx can only ever execute *after*
> the new one.
> **Note — only a date that lands in the *past* invalidates.** "Move date earlier"
> only triggers an onchain invalidation in the separate case where the new date
> is already **in the past** relative to the CheckAlive date: then the will is
> truly *expired* (`WillExpired`) and must be invalidated, exactly like a
> CheckAlive threshold that has already passed.
### 4.2 Adding an heir ### 4.2 Adding an heir
@@ -252,11 +284,12 @@ stays exactly as broadcast to the executors.
| Change % / address (only prepared) | ✅ | ❌ | | Change % / address (only prepared) | ✅ | ❌ |
| Change fee rate | ✅ | ❌ | | Change fee rate | ✅ | ❌ |
| Change / remove willexecutor | ✅ | ❌ | | Change / remove willexecutor | ✅ | ❌ |
| Move date **earlier** (anticipate) | ✅ after | ✅ **yes** | | Move date **earlier, still in the future** (anticipate) — signed **or** not | ✅ | ❌ |
| Move date **earlier into the past** (new date already passed) | ✅ after | ✅ **yes** |
| Move date **later** (postpone) — will **signed/sent** | ✅ after | ✅ **yes** | | Move date **later** (postpone) — will **signed/sent** | ✅ after | ✅ **yes** |
| Move date **later** (postpone) — will **only prepared** | ✅ | ❌ | | Move date **later** (postpone) — will **only prepared** | ✅ | ❌ |
| CheckAlive threshold already in the past | ✅ after | ✅ **yes** | | CheckAlive threshold already in the past | ✅ after | ✅ **yes** |
| Any change to an **already signed/sent** will | ✅ after | ✅ **yes** (invalidate first) | | Any change to an **already signed/sent** will **that postpones it or expires it** | ✅ after | ✅ **yes** (invalidate first) |
| Nothing changed | ❌ | ❌ | | Nothing changed | ❌ | ❌ |
--- ---
@@ -264,9 +297,11 @@ stays exactly as broadcast to the executors.
## 7. Golden rules ## 7. Golden rules
1. **Before it's signed**, changing anything is free — just **Prepare** again. 1. **Before it's signed**, changing anything is free — just **Prepare** again.
2. **After it's signed/sent**, moving the date or otherwise replacing it requires 2. **After it's signed/sent**, only **postponing** the date (moving it *later*),
an **onchain invalidation first** (a small Bitcoin fee) so an old transaction or letting it **expire** (a date now in the past), requires an **onchain
can never be executed early. invalidation first** (a small Bitcoin fee) so an old transaction can never be
executed early. **Anticipating** (moving the date *earlier*, still in the
future) never invalidates — it is just a free **rebuild**.
3. Always finish with **Sign → Broadcast → Check** so the willexecutors hold the 3. Always finish with **Sign → Broadcast → Check** so the willexecutors hold the
*current* plan (green), not an obsolete one. *current* plan (green), not an obsolete one.
4. The wallet is always **fully emptied** by the inheritance, so heir amounts must 4. The wallet is always **fully emptied** by the inheritance, so heir amounts must
@@ -274,5 +309,5 @@ stays exactly as broadcast to the executors.
--- ---
*This document reflects BAL plugin v0.3.3. Behaviour is derived directly from *This document reflects BAL plugin v0.3.4. Behaviour is derived directly from
`core/will.py` and `gui/qt/window.py`.* `core/will.py` and `gui/qt/window.py`.*

View File

@@ -0,0 +1,180 @@
"""
Diagnostic tests for A3 - "Move date earlier (anticipate)" handled manually.
These tests describe what the core decision function
``Will.check_willexecutors_and_heirs`` does TODAY when the user manually sets a
SMALLER locktime for an heir (case "A" agreed with the owner):
* Case A1: the new locktime is smaller than the frozen tx.locktime but STILL
in the future (e.g. from "90 days" to "30 days"). Per the owner's decision
(D2 = A1) this must lead to a plain REBUILD with the new locktime and must
NEVER invalidate on-chain, even if the tx was already signed/sent.
* Case A2: the new locktime is in the PAST relative to the check date. This is
a genuinely expired will and is handled by ``check_will_expired`` ->
WillExpiredException -> on-chain invalidation. This behaviour is correct and
is kept (D3 only fixes the documentation for case A1).
These are permanent regression tests. The A3 analysis confirmed the core logic
is ALREADY correct: case A1 raises a rebuild signal (a NotCompleteWillException
subclass, in practice HeirNotFoundException) and never WillExpiredException, so
no on-chain invalidation happens for an anticipate to a future date. The A3 work
itself only fixed the documentation (the table wrongly claimed "anticipate ->
always invalidate"); these tests guard that the behaviour stays correct.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_anticipate_manual_locktime.py -q
"""
import sys
import os
import copy
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest # noqa: E402
from bal.core.will import ( # noqa: E402
WillItem,
Will,
NotCompleteWillException,
WillExpiredException,
)
# A valid serialized tx (1 input + 1 output, version 2).
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
TX_FEES = 100
def _make_will_item(heirs, tx_locktime, status_complete=False):
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
is forced to ``tx_locktime`` (the value frozen inside the signed tx).
Args:
heirs: The heirs dict stored in the will item.
tx_locktime: The locktime to force into the (pretend) signed tx.
status_complete: If True, mark the item as already signed (COMPLETE).
Returns:
A configured WillItem.
"""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
return item
def _check(will_heirs, current_heirs, tx_locktime,
status_complete=False, check_date=0):
"""Run check_willexecutors_and_heirs and return the raised exception type
(or None if it returned coherent).
Args:
will_heirs: Heirs stored in the will item.
current_heirs: The (possibly edited) current heirs dict.
tx_locktime: The frozen tx.locktime.
status_complete: Whether the will tx is already signed.
check_date: The reference check date (timestamp).
Returns:
The exception class raised, or None if the will stayed coherent.
"""
item = _make_will_item(will_heirs, tx_locktime, status_complete)
will = {"willid_1": item}
try:
Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, check_date, TX_FEES,
)
return None
except Exception as e: # noqa: BLE001 - we want the type for the diagnosis
return type(e)
# A locktime far in the future (year ~2030) so it is never "in the past".
_FUTURE = 1900000000
# An even later future locktime (postpone target).
_LATER = 2000000000
# A smaller-but-still-future locktime (anticipate target, case A1).
_SMALLER_FUTURE = 1800000000
# --------------------------------------------------------------------------- #
# Case A1: smaller locktime, still in the future.
# Owner decision D2 = A1: must REBUILD with the new locktime, NEVER invalidate.
# --------------------------------------------------------------------------- #
def test_a1_anticipate_unsigned_triggers_rebuild_not_invalidate():
"""Anticipate (smaller, future locktime) on an UNSIGNED will must signal a
rebuild (a NotCompleteWillException subclass) and must NOT raise
WillExpiredException (which would invalidate on-chain)."""
will_heirs = {"alice": ["addr_alice", 5000, str(_FUTURE)]}
current_heirs = {"alice": ["addr_alice", 5000, str(_SMALLER_FUTURE)]}
raised = _check(will_heirs, current_heirs, tx_locktime=_FUTURE,
status_complete=False, check_date=0)
# Must NOT be an expiry/invalidation.
assert raised is not WillExpiredException
# Must be a rebuild signal (HeirChange / HeirNotFound, both subclasses of
# NotCompleteWillException).
assert raised is not None and issubclass(raised, NotCompleteWillException)
def test_a1_anticipate_signed_triggers_rebuild_not_invalidate():
"""Anticipate (smaller, future locktime) on a SIGNED will must STILL signal
a rebuild and must NOT invalidate on-chain (owner decision D2 = A1)."""
will_heirs = {"alice": ["addr_alice", 5000, str(_FUTURE)]}
current_heirs = {"alice": ["addr_alice", 5000, str(_SMALLER_FUTURE)]}
raised = _check(will_heirs, current_heirs, tx_locktime=_FUTURE,
status_complete=True, check_date=0)
assert raised is not WillExpiredException
assert raised is not None and issubclass(raised, NotCompleteWillException)
# --------------------------------------------------------------------------- #
# Case A2: smaller locktime that lands in the PAST -> genuine expiry.
# This is handled by check_will_expired (separate from check_willexecutors_and_heirs)
# and is intentionally NOT changed by A3.
# --------------------------------------------------------------------------- #
def test_a2_past_locktime_is_genuinely_expired():
"""A locktime in the PAST (relative to the check date) is a genuine expiry
handled by check_will_expired -> WillExpiredException. This is kept."""
item = _make_will_item(
{"alice": ["addr_alice", 5000, str(1000)]}, tx_locktime=1000,
)
item.set_status("VALID", True)
will = {"willid_1": item}
all_inputs = Will.get_all_inputs(will, only_valid=True)
min_lt = Will.get_all_inputs_min_locktime(all_inputs)
# check_date well after the tx locktime -> expired.
with pytest.raises(WillExpiredException):
Will.check_will_expired(min_lt, timestamp_to_check=_FUTURE)
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All A3 anticipate diagnostic tests passed")

View File

@@ -0,0 +1,121 @@
"""
Focused regression/diagnostic test for the "anticipate near expiry" edge case.
Scenario reported by the plugin owner
-------------------------------------
When the user adds funds to the wallet, the plugin must rebuild the inheritance
transaction. To make the new transaction supersede the old one, the plugin
anticipates the locktime (moves it EARLIER) by a fixed amount (1 day), so the
new tx confirms before the old one and the old one gets invalidated.
That logic is fine when the will is far from its locktime. But if the will is
about to expire (e.g. locktime is only a few HOURS in the future), anticipating
by a full day pushes the new locktime INTO THE PAST.
This test verifies, against the real code, what value the anticipation logic
produces in that situation, and whether any guard prevents a past locktime.
It is a *diagnostic* test: it documents the current behaviour so we can decide
whether a fix is needed. Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_anticipate_past_locktime.py -q
"""
import sys
import os
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import Util, LOCKTIME_THRESHOLD
# ---------------------------------------------------------------------------
# 1. Pure-function level: Util.anticipate_locktime has no "now" lower bound.
# ---------------------------------------------------------------------------
def test_anticipate_locktime_can_fall_into_the_past():
"""A timestamp locktime only 10 hours away, anticipated by 1 day, lands
~14 hours in the PAST. The only clamp in anticipate_locktime is `out < 1`,
so a past-but-positive timestamp is returned unchanged."""
now = int(time.time())
locktime_in_10h = now + 10 * 3600 # expires in 10 hours
assert locktime_in_10h > LOCKTIME_THRESHOLD # it is a timestamp locktime
anticipated = int(Util.anticipate_locktime(locktime_in_10h, days=1))
# The anticipated locktime is earlier than the original (as intended)...
assert anticipated < locktime_in_10h
# ...but it is now in the PAST relative to "now":
assert anticipated < now, (
f"anticipated={anticipated} is not in the past relative to now={now}; "
"the edge case may have been fixed"
)
# And crucially it is NOT clamped to anything sensible like `now`; it is
# exactly original - 86400.
assert anticipated == locktime_in_10h - 86400
def test_anticipate_locktime_far_from_expiry_stays_in_future():
"""Control case: when the will is far from expiry (e.g. 30 days away),
anticipating by 1 day keeps the locktime safely in the future."""
now = int(time.time())
locktime_in_30d = now + 30 * 86400
anticipated = int(Util.anticipate_locktime(locktime_in_30d, days=1))
assert anticipated < locktime_in_30d
assert anticipated > now # still in the future -> safe
# ---------------------------------------------------------------------------
# 2. chk_locktime confirms the produced value is considered "expired".
# ---------------------------------------------------------------------------
def test_past_anticipated_locktime_is_seen_as_expired():
"""The anticipated past locktime, when fed to chk_locktime against the
current time, is reported as NOT in the future (i.e. already expired)."""
now = int(time.time())
locktime_in_10h = now + 10 * 3600
anticipated = int(Util.anticipate_locktime(locktime_in_10h, days=1))
# chk_locktime signature is now (timestamp_to_check, locktime) (A1):
# it returns True only if the locktime is still in the future.
in_future = Util.chk_locktime(now, anticipated)
assert in_future is False, (
"the anticipated locktime is unexpectedly still in the future"
)
# ---------------------------------------------------------------------------
# 3. Boundary scan: at what original distance does anticipation start to
# produce a past locktime? Documents the 24h threshold explicitly.
# ---------------------------------------------------------------------------
def test_boundary_is_exactly_24h():
now = int(time.time())
# Just under 24h away -> anticipated into the past.
just_under = now + 24 * 3600 - 60
a1 = int(Util.anticipate_locktime(just_under, days=1))
assert a1 < now
# Just over 24h away -> anticipated value stays (barely) in the future.
just_over = now + 24 * 3600 + 60
a2 = int(Util.anticipate_locktime(just_over, days=1))
assert a2 > now
if __name__ == "__main__":
test_anticipate_locktime_can_fall_into_the_past()
test_anticipate_locktime_far_from_expiry_stays_in_future()
test_past_anticipated_locktime_is_seen_as_expired()
test_boundary_is_exactly_24h()
# Human-readable demonstration
now = int(time.time())
for hours in (10, 23, 25, 48, 24 * 30):
lt = now + hours * 3600
a = int(Util.anticipate_locktime(lt, days=1))
delta_h = (a - now) / 3600.0
verdict = "PAST <-- problem" if a < now else "future (ok)"
print(
f"expires in {hours:>4}h -> anticipated locktime is "
f"{delta_h:+.1f}h from now [{verdict}]"
)
print("[OK] all anticipate-past-locktime diagnostics passed")

View File

@@ -13,6 +13,8 @@ import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
import pytest
from bal.core.util import Util, LOCKTIME_THRESHOLD from bal.core.util import Util, LOCKTIME_THRESHOLD
@@ -32,10 +34,14 @@ def test_locktime_to_str():
def test_str_to_locktime(): def test_str_to_locktime():
# relative suffixes pass through # relative suffixes pass through (only days "d" and years "y" are supported)
assert Util.str_to_locktime("30d") == "30d" assert Util.str_to_locktime("30d") == "30d"
assert Util.str_to_locktime("1y") == "1y" assert Util.str_to_locktime("1y") == "1y"
assert Util.str_to_locktime("144b") == "144b"
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
# relative locktime, so it is NOT passed through unchanged.
with pytest.raises(Exception):
Util.str_to_locktime("144b")
# integer string -> int # integer string -> int
assert isinstance(Util.str_to_locktime("500000"), int) assert isinstance(Util.str_to_locktime("500000"), int)
@@ -69,12 +75,12 @@ def test_parse_locktime_string():
def test_int_locktime(): def test_int_locktime():
# int_locktime no longer accepts a "blocks" argument (A1): locktimes are
# always expressed in seconds (timestamps), never in block counts.
assert Util.int_locktime(seconds=1) == 1 assert Util.int_locktime(seconds=1) == 1
assert Util.int_locktime(minutes=1) == 60 assert Util.int_locktime(minutes=1) == 60
assert Util.int_locktime(hours=1) == 3600 assert Util.int_locktime(hours=1) == 3600
assert Util.int_locktime(days=1) == 86400 assert Util.int_locktime(days=1) == 86400
assert Util.int_locktime(blocks=1) == 600
assert Util.int_locktime(days=1, blocks=1) == 86400 + 600
assert Util.int_locktime() == 0 assert Util.int_locktime() == 0
@@ -223,40 +229,35 @@ def test_get_value_amount():
def test_chk_locktime(): def test_chk_locktime():
# chk_locktime signature is now (timestamp_to_check, locktime) (A1):
# block-height handling was removed, locktimes are always timestamps.
now_ts = 1700000000 now_ts = 1700000000
now_block = 800000
# timestamp locktime still in future # timestamp locktime still in future
assert Util.chk_locktime(now_ts, now_block, 1800000000) is True assert Util.chk_locktime(now_ts, 1800000000) is True
# timestamp locktime in past # timestamp locktime in past
assert Util.chk_locktime(now_ts, now_block, 1000000000) is False assert Util.chk_locktime(now_ts, 1000000000) is False
# block-height locktime still in future
assert Util.chk_locktime(now_ts, now_block, 900000) is True
# block-height locktime in past
assert Util.chk_locktime(now_ts, now_block, 100000) is False
def test_anticipate_locktime(): def test_anticipate_locktime():
# block-height style (note: "anticipate" actually adds for block locktimes) # anticipate_locktime no longer accepts a "blocks" argument (A1):
result = Util.anticipate_locktime(800000, blocks=100) # it only moves a timestamp earlier (by hours/days).
assert result == 800000 + 100
# timestamp style # timestamp style: anticipating by 1 day moves the locktime earlier
ts = 1700000000 ts = 1700000000
result = Util.anticipate_locktime(ts, days=1) result = Util.anticipate_locktime(ts, days=1)
assert result < ts assert result < ts
assert result > 0 assert result > 0
assert result == ts - 86400
# overflow handling (Windows-safe) # overflow handling (Windows-safe)
huge = 2**32 - 1 # NLOCKTIME_MAX huge = 2**32 - 1 # NLOCKTIME_MAX
result = Util.anticipate_locktime(huge, days=1) result = Util.anticipate_locktime(huge, days=1)
assert result > 0 assert result > 0
# clamp to minimum 1 # clamp to minimum 1 (anticipating a tiny value never goes below 1)
low = Util.anticipate_locktime(10, blocks=100) low = Util.anticipate_locktime(10, days=1)
assert low >= 1 assert low >= 1

View File

@@ -96,7 +96,9 @@ def test_check_invalidated_confirmed():
assert item.get_status("CONFIRMED") is True assert item.get_status("CONFIRMED") is True
def test_check_invalidated_pending(): def test_check_invalidated_mempool():
# PENDING was renamed to MEMPOOL (A2): a tx seen with height 0 (in the
# mempool, not yet mined) is flagged as MEMPOOL.
wallet = MagicMock() wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0 wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
@@ -104,7 +106,65 @@ def test_check_invalidated_pending():
"time": 0, "change": "", "baltx_fees": 100}) "time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item} will = {"wid": item}
Will.check_invalidated(will, [], wallet) Will.check_invalidated(will, [], wallet)
assert item.get_status("PENDING") is True assert item.get_status("MEMPOOL") is True
def test_legacy_pending_migrates_to_mempool():
# Backward-compatibility (A2, "Modo B"): a will saved by an older plugin
# version stores the flag under the legacy "PENDING" key. Loading it must
# carry that flag over to the new "MEMPOOL" status, so nothing is lost.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100,
"PENDING": True})
assert item.get_status("MEMPOOL") is True
def test_new_mempool_wins_over_legacy_pending():
# If both the new "MEMPOOL" key and the legacy "PENDING" key are present,
# the new key wins: an explicit MEMPOOL=False is NOT overridden by a stale
# legacy PENDING=True.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100,
"MEMPOOL": False, "PENDING": True})
assert item.get_status("MEMPOOL") is False
def test_updated_status_keeps_valid():
# A2 rule: setting UPDATED must NOT clear the VALID flag (the tx is replaced
# by a new one that keeps the same locktime and same heirs).
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100,
"VALID": True})
item.set_status("UPDATED", True)
assert item.get_status("UPDATED") is True
assert item.get_status("VALID") is True
def test_anticipated_status_keeps_valid():
# A2 rule: setting ANTICIPATED must NOT clear the VALID flag (anticipating
# only moves the locktime 1 day earlier; the tx stays valid).
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100,
"VALID": True})
item.set_status("ANTICIPATED", True)
assert item.get_status("ANTICIPATED") is True
assert item.get_status("VALID") is True
def test_mempool_status_clears_valid():
# A2 rule (unchanged from old PENDING behaviour): setting MEMPOOL clears the
# VALID flag.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100,
"VALID": True})
item.set_status("MEMPOOL", True)
assert item.get_status("MEMPOOL") is True
assert item.get_status("VALID") is False
def test_check_invalidated_invalidated(): def test_check_invalidated_invalidated():
@@ -129,9 +189,11 @@ def test_check_will():
"willexecutor": None, "status": "", "description": "", "willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}) "time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item} will = {"wid": item}
Will.check_will(will, [], wallet, 100, 9999999999) # check_will signature is now (will, all_utxos, wallet, timestamp_to_check):
# should be PENDING (height=0) # block_to_check was removed in A1 (locktimes are always timestamps).
assert item.get_status("PENDING") is True Will.check_will(will, [], wallet, 9999999999)
# should be MEMPOOL (height=0); PENDING was renamed to MEMPOOL in A2.
assert item.get_status("MEMPOOL") is True
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,155 @@
"""
Tests for Group B / B2 and its follow-up fixes.
Covered behaviour:
* the persisted ``AUTO_SIGN`` configuration key exists and defaults to ON,
and can be turned off and read back;
* the manual "next step (Sign/Broadcast)" hint is suppressed when AUTO_SIGN
is ON (the Building Will dialog already signs and broadcasts), and shown
when AUTO_SIGN is OFF (Fix A - no duplicate "press Broadcast" popup);
* the broadcast is "one-shot": transactions already marked PUSHED are not
collected again for re-sending, so a will-executor that failed before does
not cause the successful ones to be re-broadcast (Fix B).
The Qt classes are not imported (they require PyQt6 + an Electrum window).
Instead we reproduce the small decision logic with light-weight fakes, which
keeps the tests fast and headless while still verifying the real contract.
Run:
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_b_auto_sign.py -q
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Mocks
# ------------------------------------------------------------------ #
class FakeConfig:
"""Minimal mock for Electrum's config object (key/value store)."""
def __init__(self):
self._store = {}
def get(self, key, default=None):
return self._store.get(key, default)
def set_key(self, key, value, save=True):
self._store[key] = value
class FakeWillItem:
"""Minimal will item exposing the status flags and will-executor used by
``Willexecutors.get_willexecutor_transactions``.
``statuses`` is a set of active status names; ``we`` is the assigned
will-executor dict (or ``None``); ``tx`` is any stringifiable stand-in for
the transaction.
"""
def __init__(self, statuses, we, tx="rawtx"):
self._statuses = set(statuses)
self.we = we
self.tx = tx
def get_status(self, name):
return name in self._statuses
# ------------------------------------------------------------------ #
# AUTO_SIGN config default
# ------------------------------------------------------------------ #
def test_auto_sign_config_defaults_on():
"""AUTO_SIGN must default to ON (True) when not yet stored."""
cfg = FakeConfig()
auto_sign = BalConfig(cfg, "bal_auto_sign", True)
assert auto_sign.get() is True
def test_auto_sign_config_can_be_disabled():
"""Once turned off and persisted, AUTO_SIGN reads back as False."""
cfg = FakeConfig()
auto_sign = BalConfig(cfg, "bal_auto_sign", True)
auto_sign.set(False)
assert auto_sign.get() is False
# A fresh wrapper over the same config still sees the stored value.
assert BalConfig(cfg, "bal_auto_sign", True).get() is False
# ------------------------------------------------------------------ #
# Fix A - manual "next step" hint suppressed when AUTO_SIGN is ON
# ------------------------------------------------------------------ #
def _should_show_manual_hint(auto_sign_on):
"""Reproduce the guard added at the top of _show_next_steps_hint().
Returns True when the manual Sign/Broadcast hint (and follow-up popup)
should be shown. With AUTO_SIGN ON the dialog has already signed and
broadcast, so the hint must be suppressed.
"""
if auto_sign_on:
return False
return True
def test_hint_suppressed_when_auto_sign_on():
assert _should_show_manual_hint(auto_sign_on=True) is False
def test_hint_shown_when_auto_sign_off():
assert _should_show_manual_hint(auto_sign_on=False) is True
# ------------------------------------------------------------------ #
# Fix B - PUSHED transactions are not collected again (one-shot broadcast)
# ------------------------------------------------------------------ #
def test_pushed_tx_not_recollected():
"""A VALID+COMPLETE+PUSHED will must NOT be collected for re-broadcast."""
we = {"url": "https://we.example", "selected": True}
will = {
"tx_done": FakeWillItem(
{"VALID", "COMPLETE", "PUSHED"}, dict(we)
),
}
collected = Willexecutors.get_willexecutor_transactions(will)
# Nothing to send: the only will is already PUSHED.
assert collected == {}
def test_unpushed_tx_is_collected():
"""A VALID+COMPLETE but NOT-yet-PUSHED will IS collected for broadcast."""
we = {"url": "https://we.example", "selected": True}
will = {
"tx_new": FakeWillItem({"VALID", "COMPLETE"}, dict(we)),
}
collected = Willexecutors.get_willexecutor_transactions(will)
assert "https://we.example" in collected
assert "tx_new" in collected["https://we.example"]["txsids"]
def test_mixed_only_unpushed_collected():
"""With one PUSHED and one not-pushed will on different servers, only the
not-pushed one is collected (the successful one is never re-sent)."""
will = {
"tx_done": FakeWillItem(
{"VALID", "COMPLETE", "PUSHED"},
{"url": "https://ok.example", "selected": True},
),
"tx_todo": FakeWillItem(
{"VALID", "COMPLETE"},
{"url": "https://todo.example", "selected": True},
),
}
collected = Willexecutors.get_willexecutor_transactions(will)
assert "https://ok.example" not in collected
assert "https://todo.example" in collected

View File

@@ -33,7 +33,8 @@ def test_color_invalidated():
def test_color_invalidated_overrides_lower(): def test_color_invalidated_overrides_lower():
item = FakeWillItem(INVALIDATED=True, PENDING=True, COMPLETE=True) # PENDING was renamed to MEMPOOL (A2).
item = FakeWillItem(INVALIDATED=True, MEMPOOL=True, COMPLETE=True)
assert status_color(item) == "#f87838" assert status_color(item) == "#f87838"
@@ -41,12 +42,20 @@ def test_color_replaced():
assert status_color(FakeWillItem(REPLACED=True)) == "#ff97e9" assert status_color(FakeWillItem(REPLACED=True)) == "#ff97e9"
def test_color_updated():
# UPDATED is a new status (A2): light violet. It is checked after REPLACED
# but before CONFIRMED/MEMPOOL in the priority list. The original violet
# (#800080) was too dark to read, so it was lightened to #b266b2.
assert status_color(FakeWillItem(UPDATED=True)) == "#b266b2"
def test_color_confirmed(): def test_color_confirmed():
assert status_color(FakeWillItem(CONFIRMED=True)) == "#bfbfbf" assert status_color(FakeWillItem(CONFIRMED=True)) == "#bfbfbf"
def test_color_pending(): def test_color_mempool():
assert status_color(FakeWillItem(PENDING=True)) == "#ffce30" # PENDING was renamed to MEMPOOL (A2); the colour (yellow) is unchanged.
assert status_color(FakeWillItem(MEMPOOL=True)) == "#ffce30"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -118,11 +118,16 @@ def test_locktime_editor_min_max():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_locktime_raw_edit_replace_str(): def test_locktime_raw_edit_replace_str():
# replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now).
from bal.gui.qt.widgets import LockTimeRawEdit from bal.gui.qt.widgets import LockTimeRawEdit
assert LockTimeRawEdit.replace_str("123d") == "123" assert LockTimeRawEdit.replace_str("123d") == "123"
assert LockTimeRawEdit.replace_str("456y") == "456" assert LockTimeRawEdit.replace_str("456y") == "456"
assert LockTimeRawEdit.replace_str("789b") == "789" # "b" is left untouched (no longer a recognised suffix)
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456" assert LockTimeRawEdit.replace_str("789b") == "789b"
# only d/y are stripped; a stray "b" remains
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b"
def test_locktime_raw_edit_checkbdy(): def test_locktime_raw_edit_checkbdy():