forked from bitcoinafterlife/bal-electrum-plugin
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:
380
CHANGELOG.md
Normal file
380
CHANGELOG.md
Normal 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.
|
||||||
@@ -1 +1 @@
|
|||||||
0.3.3
|
0.3.4
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,32 +349,27 @@ 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:
|
|
||||||
seconds = blocks * 600 + hours * 3600 + days * 86400
|
|
||||||
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
||||||
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
||||||
try:
|
try:
|
||||||
@@ -371,9 +378,6 @@ class Util:
|
|||||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
||||||
dt -= timedelta(seconds=seconds)
|
dt -= timedelta(seconds=seconds)
|
||||||
out = dt.timestamp()
|
out = dt.timestamp()
|
||||||
else:
|
|
||||||
blocks -= hours * 6 + days * 144
|
|
||||||
out = locktime + blocks
|
|
||||||
|
|
||||||
if out < 1:
|
if out < 1:
|
||||||
out = 1
|
out = 1
|
||||||
|
|||||||
151
bal/core/will.py
151
bal/core/will.py
@@ -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,18 +653,23 @@ 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):
|
|
||||||
raise WillExpiredException(
|
|
||||||
f"Will Expired {wid[0][0]}: {locktime}<{block_to_check}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if locktime < int(timestamp_to_check):
|
if locktime < int(timestamp_to_check):
|
||||||
raise WillExpiredException(
|
raise WillExpiredException(
|
||||||
f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}"
|
f"Will Expired {wid[0][0]}: {locktime}<{timestamp_to_check}"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -782,10 +790,10 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 |
@@ -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 on‑chain</strong></td><td>seen on‑chain, height > 0</td></tr>
|
<tr><td><code>CONFIRMED</code></td><td>Tx <strong>mined on‑chain</strong></td><td>seen on‑chain, height > 0</td></tr>
|
||||||
<tr><td><code>PENDING</code></td><td>Tx <strong>in the mempool</strong></td><td>seen on‑chain, height 0</td></tr>
|
<tr><td><code>MEMPOOL</code></td><td>Tx <strong>in the Electrum mempool</strong></td><td>seen on‑chain, 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 earlier‑locktime 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 pre‑existing 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>same‑locktime 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 @@ already‑signed 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>Check‑Alive threshold already passed</td><td>—</td><td><strong>Invalidate</strong> (CheckAliveError)</td><td class="fee">Yes</td></tr>
|
<tr><td>Check‑Alive 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 on‑chain invalidation:</strong> the executor still holds the <em>old</em>
|
<blockquote><strong>Why postpone needs on‑chain invalidation:</strong> the executor still holds the <em>old</em>
|
||||||
transaction with the <em>earlier</em> locktime. Spending its inputs on‑chain makes it un‑minable, so it can
|
transaction with the <em>earlier</em> locktime. Spending its inputs on‑chain makes it un‑minable, so it can
|
||||||
never execute the inheritance early. The plugin tells you this and offers to build the invalidation tx.</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 on‑chain:</strong> moving the delivery
|
||||||
|
date <em>earlier</em> only makes the inheritance available <em>sooner</em>; there is no early‑execution risk,
|
||||||
|
so the plugin simply <strong>rebuilds</strong> the transactions with the new earlier locktime — <strong>no
|
||||||
|
on‑chain 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 — re‑Broadcast
|
|||||||
<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 will‑executor</td><td class="yes">Yes</td><td class="no">No</td></tr>
|
<tr><td>Change / remove will‑executor</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>Check‑Alive threshold already passed</td><td class="yes">Yes after</td><td class="fee">Yes</td></tr>
|
<tr><td>Check‑Alive 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 — re‑Broadcast
|
|||||||
<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>on‑chain
|
<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>on‑chain 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 will‑executor(s)** | after **Broadcast** to executors |
|
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
||||||
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
| `CHECKED` | The will‑executor **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 on‑chain** | seen on‑chain with height > 0 |
|
| `CONFIRMED` | The tx is **mined on‑chain** | seen on‑chain with height > 0 |
|
||||||
| `PENDING` | The tx is **in the mempool** (height 0) | seen on‑chain, not yet mined |
|
| `MEMPOOL` | The tx is **in the Electrum mempool** (height 0) | seen on‑chain, 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 pre‑existing 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 same‑locktime 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 will‑executors hold — not the in‑memory copy.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Move date LATER** (postpone) | **No** (never signed) | Plain **rebuild** (`HeirNotFound` fall‑through) | **No** |
|
| **Move date LATER** (postpone) | **No** (never signed) | Plain **rebuild** (`HeirNotFound` fall‑through) | **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` fall‑through) — **never** an on‑chain invalidation | **No** |
|
||||||
|
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
|
||||||
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
||||||
|
|
||||||
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
||||||
@@ -152,9 +169,24 @@ exactly what the will‑executors hold — not the in‑memory copy.
|
|||||||
> Spending the old transaction's inputs on‑chain makes the old tx **un‑minable**.
|
> Spending the old transaction's inputs on‑chain makes the old tx **un‑minable**.
|
||||||
> 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 on‑chain:** moving the date earlier makes the current
|
> **Why anticipate (move earlier, still future) is NOT on‑chain:** 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 early‑execution 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 on‑chain 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 will‑executor 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 on‑chain invalidation in the separate case where the new date
|
||||||
|
> is already **in the past** relative to the Check‑Alive date: then the will is
|
||||||
|
> truly *expired* (`WillExpired`) and must be invalidated, exactly like a
|
||||||
|
> Check‑Alive 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 will‑executor | ✅ | ❌ |
|
| Change / remove will‑executor | ✅ | ❌ |
|
||||||
| 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** | ✅ | ❌ |
|
||||||
| Check‑Alive threshold already in the past | ✅ after | ✅ **yes** |
|
| Check‑Alive 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 **on‑chain invalidation first** (a small Bitcoin fee) so an old transaction
|
or letting it **expire** (a date now in the past), requires an **on‑chain
|
||||||
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 will‑executors hold the
|
3. Always finish with **Sign → Broadcast → Check** so the will‑executors 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`.*
|
||||||
|
|||||||
180
tests/test_anticipate_manual_locktime.py
Normal file
180
tests/test_anticipate_manual_locktime.py
Normal 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")
|
||||||
121
tests/test_anticipate_past_locktime.py
Normal file
121
tests/test_anticipate_past_locktime.py
Normal 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")
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
155
tests/test_group_b_auto_sign.py
Normal file
155
tests/test_group_b_auto_sign.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user