22 Commits

Author SHA1 Message Date
2221389f44 core: anchor relative locktime/threshold recipes to the built tx locktime so an unchanged will no longer reads as expired/postponed; stop the daily invalidate prompt (karen7) 2026-08-09 12:08:25 -04:00
95c23a4b21 core: extract GUI-free logic into bal.core (reminders/checkalive/input_rules); fix save/sign tx RLock pickle by serializing tx; keep invalid heirs in wallet on build; tb1 testnet will-executor addresses; move tests to core modules 2026-08-05 17:17:32 -04:00
0806332543 core: replace debug print() with _logger, drop dead print_transaction helper 2026-08-01 23:33:52 -04:00
1e80f7a0e0 docs: align all markdown docs with current code (v0.6.1)
- CHANGELOG: add entries #47 (is_selected/is_valid fee bounds, extremes allowed) and #48 (merge_will crash fix on missing date_to_check)
- HANDOFF: Electrum 4.7.2 and 4.8.0, current layout (qt.py, plugin.py, wallet_util/, docs/, bal_cli.py, 36-file ZIP), runtime/lint venv paths, standalone-script tests (427 collected), direct-to-main workflow, version history up to v0.6.1
- README: drop VERSION from layout, correct anticipate/expire/postpone behavior, update test commands
- docs/manual/README.md: fix Tools menu label to Will-Executors, add BASIC/ADVANCED notes, extend chromatic status table (Partially signed, Updated)
- docs/inheritance-options.md: add PARTIALLY_SIGNED status/transition/colour, version note to v0.6.1
- docs/README.md: make HTML-hosting instructions host-agnostic
- bal/README.md: Prepare button lives on the WILL tab
2026-08-01 23:08:07 -04:00
693479b0da willexecutors: fee-bound is_valid (extremes allowed), is_selected flag-only; fix merge_will crash on missing date_to_check; DNSSEC async, ical folding, pyright/ruff cleanup; refresh karen7/samanta7 fixtures 2026-08-01 22:08:53 -04:00
06d61d78d0 Will toolbar: Export dropdown submenu (All / Valid / Valid NC)
Replace the separate Export / Export V-NC toolbar actions with a cascading
Export submenu: All exports every will item, Valid exports only valid ones,
Valid NC exports valid items that are not yet fully signed.
2026-08-01 19:31:28 -04:00
1dc3c79486 Will list: context menu actions and will-executor bulk tools
PreviewList: right-click menu with sign / details / broadcast / check /
copy id / copy / merge from txn / delete, with multi-selection support and
availability rules (broadcast force-repushes, delete restricted to invalid
or unsigned txs, sign skips non-valid txs).

WillExecutorWidget: Export, Ping All and Select All become dropdown buttons
(export all/selected/valid, ping all/selected, select all/only valid,
deselect all/only invalid).

WillExecutorListWidget: single-cell Copy action.

BalWindow: txids-scoped sign/broadcast plumbing and merge_single_transaction.
2026-08-01 19:11:35 -04:00
30a5720ceb BalWindow/plugin: history persistence, will import/merge, sig tracking, local-spender fixes 2026-08-01 17:21:36 -04:00
08394f4868 lint: ruff cleanup pass across bal/ and tests/
- Sort imports and fix pyproject ruff config (per-file ignores for
  intentional Qt/core exceptions)
- Mark Heirs.validate_* helpers as @staticmethod
- Clean up dead code, rename shadowing vars, use raise ... from
- Add AGENTS.md with env/lint/test/release guidance
2026-07-31 16:03:17 -04:00
649910e599 Add OP_RETURN support for heirs
Allow heirs to produce an OP_RETURN output instead of a regular BTC
payment. An address prefixed with OP_RETURN: carries hex data (max 80
bytes); such heirs always have amount 0 and are excluded from amount
calculations. GUI dialog shows a message field with the decoded text,
the heir list displays the decoded message, and the OP_RETURN output
is emitted with zero value in inheritance transactions.
2026-07-30 22:06:49 -04:00
fb797f31e3 fix version lookup for internal plugin install; silence JSON parse debug noise 2026-07-30 18:53:43 -04:00
f73fd14441 add samanta7 for tests 2026-07-30 18:25:52 -04:00
9bf088b7ff Add Willexecutors.is_valid + grey italic styling for invalid executors 2026-07-30 18:11:29 -04:00
4a9299d85b add make-release.sh, svatantrya.asc, and update HANDOFF.md
- make-release.sh: automated release script (tests, lint, build, GPG sign, SHA-256, Gitea)
- svatantrya.asc: PGP public key for release verification
- HANDOFF.md: updated release workflow documentation
- willexecutors.py: input validation for addresses, fees, and API responses
- manifest.json: fixed version back to 0.6.1
- test_core_plugin_base.py: updated baltx_fees default
2026-07-22 21:07:32 -04:00
c2eecce029 Merge pull request 'Update bal/core/plugin_base.py' (#4) from bitcoinafterlife-patch-5 into main 2026-07-22 15:43:19 +00:00
4b0af6f4bf v0.6.1: read version from manifest.json (single source of truth)
Read the plugin version at runtime from bal/manifest.json via
importlib.resources (zip-safe, works from inside a zip on Windows).
Removes the four-file duplication (VERSION, __init__.py, plugin_base.py,
manifest.json) and deletes the bal/VERSION file.

- bal/core/plugin_base.py: new get_version() function, BalPlugin.version
  is now a @property, removed hardcoded __version__
- bal/__init__.py: removed __version__, replaced with a comment
- bal/core/willexecutors.py: user-agent uses get_version()
- bal/gui/qt/dialogs.py, widgets.py: call sites use .version property
- tests/test_version_source.py: 7 tests incl. zip-safe subprocess check
- HANDOFF.md: updated to single-source-of-truth documentation
- CHANGELOG.md: entries 43-46
- bal/manifest.json: version bumped to 0.6.1

PR #4 (branch bitcoinafterlife-patch-5).
2026-07-22 11:19:18 -04:00
f72ed33ea0 Merge pull request 'Add COMPATIBILITY_ROADMAP.md - multisig and TrustedCoin analysis' (#6) from SAFE21.io/bal-electrum-plugin:update-compat-docs into main
Reviewed-on: #6
2026-07-22 09:16:27 +00:00
6c41a28541 Merge pull request 'Update CHANGELOG.md' (#3) from bitcoinafterlife-patch-6 into main
Reviewed-on: #3
2026-07-22 09:16:03 +00:00
00eefe0525 Merge pull request 'Add COMPATIBILITY.md and update README for 4.8.0 support' (#5) from update-compat-docs into main
Reviewed-on: #5
2026-07-22 08:55:28 +00:00
bitcoinafterlife
b610bea5a8 Add compatibility roadmap for multisig and TrustedCoin (2FA) wallets 2026-07-19 16:51:16 +02:00
bitcoinafterlife
4ae019cba5 Add COMPATIBILITY.md; update README with 4.8.0 support and wallet compat section 2026-07-19 11:08:09 +02:00
372f3952ca Update CHANGELOG.md
v0.6.0: update changelog
2026-07-17 17:42:13 +00:00
79 changed files with 11596 additions and 3342 deletions

29
.gitignore vendored
View File

@@ -4,3 +4,32 @@
bal-electrum-plugin.zip bal-electrum-plugin.zip
electrum-src/ electrum-src/
preview_*.png preview_*.png
.env
# Virtual environment
venv/
.venv/
# Node modules
node_modules/
# Editor temp files
*.swp
*.swo
*.bak
# Local tooling / scratch files (not part of the plugin)
TODO_LIST.md
opencode.json
package.json
package-lock.json
pyrightconfig.json
# Debug / scratch files
debug.py
init.ol
temp*
tmp*
# Release artifacts
bal_v*.zip.*

76
AGENTS.md Normal file
View File

@@ -0,0 +1,76 @@
# AGENTS.md
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
## Environments (critical)
Two separate venvs; using the wrong one is the #1 mistake.
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source /home/steal/devel/bal/electrum/env/bin/activate`
This is an editable install of the Electrum 4.8.0 checkout at
`/home/steal/devel/bal/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests.
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
The plugin's `bal/` directory is symlinked into
`electrum/electrum/plugins/bal` (internal-plugin install used during dev).
## Test & verify
Tests are **standalone scripts**, not pytest. Each `tests/test_*.py` file runs
its `test_*` functions from `if __name__ == "__main__"`. Run a file directly:
```bash
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_heirs.py # core, no Qt needed
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
```
- Most core tests run offline (no wallet/network). Some files
(`test_group_*.py`, `test_no_willexecutor_karen7.py`, `parallel_ping_test.py`)
exercise will-executor/network flows and need the live servers — don't rely on
them for quick verification.
- `tests/smoke_test.py` proves clean import under real Electrum:
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
- `tests/external_zip_test.py` loads the built zip the way Electrum's plugin
dialog does (`electrum_external_plugins.bal`); run it after `build_zip.py`.
## Lint / typecheck
- **Ruff is NOT clean** (hundreds of pre-existing errors in `bal/` and
`tests/`). Do not run `--fix` wholesale and do not try to silence everything;
just avoid adding new violations. Config: `pyproject.toml` (line-length 88,
E501 ignored).
- Lint via the repo venv: `/home/steal/devel/bal/bal-electrum-plugin/venv/bin/ruff`
- Typecheck: `pyright` (npm, `node_modules/`), config `pyrightconfig.json`
(`extraPaths: ["../electrum"]`). Pyright reports many false positives on
dynamically-attached attrs (e.g. `self.window`, `BalPlugin.*`); don't chase
them.
## Architecture
- `bal/core/` = GUI-free logic (`heirs.py`, `will.py`, `willexecutors.py`,
`plugin_base.py`, `util.py`). Must never import Qt.
- `bal/gui/qt/` = PyQt6 layer. `window.py` is the per-wallet controller,
`plugin.py` is the Electrum `@hooks` entry, `qt.py` is a zipimport shim.
- `bal/manifest.json` = version source of truth (Electrum reads it; also read by
`make-release.sh`).
- Compatibility constraint: must support Electrum **4.7.2 and 4.8.0**; the DB
registration API differs between them (`json_db.register_dict` vs
`stored_dict.register_name`).
## Build / release
```bash
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256)
./make-release.sh [v0.x.y] # bump manifest version, tag, sign, push Gitea release
```
- `make-release.sh` requires gpg and Gitea credentials (`~/.git-credentials`
or `GITEA_USER`/`GITEA_TOKEN`). It bumps `bal/manifest.json` — bump the
version there, never invent a new source of truth.
- Remote is Gitea (`origin` = bitcoin-after.life). `.env` holds a Gitea token
(gitignored, never commit it).

View File

@@ -2329,3 +2329,242 @@ The request was to make the failure message clearer (no timeout change).
- `ruff`: no new errors. - `ruff`: no new errors.
**Outcome:** DONE (delivered as test ZIP v0.5.18; commit only after confirmation). **Outcome:** DONE (delivered as test ZIP v0.5.18; commit only after confirmation).
---
## 43. v0.6.0 - Version bump for the official repository release
**Date:** 2026-07-17
**Context:** the plugin content of this version is IDENTICAL to v0.5.18 - no code
changes. A release was published on the official repository
(bitcoinafterlife/bal-electrum-plugin) tagged "v0.6.0", but the internal version
files still read "0.5.18" (a human oversight: the release tag was not matched by
a version bump in the code), so Electrum displayed "0.5.18" after installing it.
This entry aligns the internal version with the intended "0.6.0" release tag by
bumping `bal/VERSION`, `bal/manifest.json`, `bal/__init__.py` and
`bal/core/plugin_base.py` from 0.5.18 to 0.6.0. No functional changes.
**Verification:** full test suite against Electrum 4.7.2 and 4.8.0 (same 266
passed / 2 pre-existing unrelated failures as v0.5.18); `ruff` clean.
**Outcome:** DONE.
---
## 44. Repo: inheritance-logic test suite + first BAL CLI (no plugin version change)
**Date:** 2026-07-20
**Scope note:** this entry adds REPOSITORY files only (`tests/`, `bal_cli.py`).
The plugin package `bal/` is byte-for-byte unchanged, so the plugin version
stays **0.6.0** on purpose (no zip rebuild, no version bump - avoiding any new
tag/code mismatch).
**1) New logic test suite - `tests/test_inheritance_scenarios.py` (23 tests):**
- Multi-heir distribution via `Heirs.prepare_lists`: 50/50 percent split of
(balance - fees); percentages normalized to their SUM (30+30 behaves like
50/50); fixed heirs paid first with percents sharing the remainder; fixed
amounts exceeding the balance scaled down proportionally (`onlyfixed`).
- Dust rules: below-dust heirs marked `DUST:` while valid heirs keep building;
the leftover redistribution can LIFT dust fixed heirs above the threshold
(pinned: 100:200 on 1M -> 333333/666666); all-dust wills refused
(`HeirAmountIsDustException`); `BalanceTooLowException` when balance < fees.
- Will-executor fees: one pseudo-heir per distinct locktime; fees larger than
the balance raise `WillExecutorFeeException`.
- Expired-heir exclusion vs `from_locktime`; grouping of heirs by locktime.
- Heir add/remove/locktime-change all flag the will as changed
(`Will._same_heirs`); the `Heirs` mapping persists on add/remove.
- Will expiry (`Will.check_will_expired`): past locktime raises
`WillExpiredException`, future/boundary (== now) does not, non-VALID items
are ignored.
- Locktime parsing: int passthrough, `<n>d` -> future midnight, `1y` == `365d`.
**2) First BAL CLI - `bal_cli.py` (offline iteration):**
- Reuses `bal.core` directly with Electrum as a library (no Qt, headless).
- Commands: `heirs list/add/remove/export/import`, `status`.
- Safety: **testnet by default**, mainnet only with an explicit `--mainnet`
flag (active network always printed); encrypted wallets via `--password` or
`BAL_WALLET_PASSWORD` env var; address/locktime validation (rejects wrong
network and past locktimes); guaranteed process termination (electrum leaves
non-daemon threads even offline - the CLI stops the wallet and event loop,
then hard-exits with the proper exit code, so machine callers never hang).
- Planned next iteration: `will build/sign/push/check` with an explicit
`--yes` confirmation flag for automation.
- End-to-end smoke test `tests/test_cli_smoke.py`: creates a REAL testnet
wallet via electrum-as-library in a subprocess, then drives
add/list/reject-invalid-address/reject-past-locktime/export/remove/status.
**Verification:**
- Full suite against **Electrum 4.7.2**: `290 passed`; against **4.8.0**:
`290 passed` (same 2 pre-existing, unrelated `baltx_fees` failures in both).
- `ruff`: no new errors on the three new files.
**Outcome:** DONE.
---
## 45. Repo: CLI will commands (build / sign / push / check) - no plugin version change
**Date:** 2026-07-21
**Goal:** complete the headless workflow started in entry #44, so a machine can
run the whole inheritance cycle without the Qt GUI.
**What was added (`bal_cli.py`):**
- `will build` - builds the will transactions offline through the same
`bal.core` pipeline the GUI uses (`Heirs.get_transactions` -> `WillItem` ->
`Will.update_will` / `normalize_will`), storing them as "New".
- `will sign` - signs the valid, incomplete transactions with the wallet
password (`--password` / `BAL_WALLET_PASSWORD`). Prints a summary and asks
for confirmation; `--yes` skips it for automation. Chained will inputs
(spending a previous will's change) are patched exactly as the GUI does.
Watching-only wallets and hardware keystores are rejected with a clear
message (hardware devices need physical confirmation).
- `will push` - sends signed transactions to the selected will-executors via
`Willexecutors.push_transactions_parallel`; `--yes` and `--force` supported;
updates PUSHED / PUSH_FAIL statuses.
- `will check` - read-only report with **exit codes for scripts**: 0 valid,
complete and pushed; 2 no will stored; 3 EXPIRED; 4 not fully signed;
5 signed but not pushed; 6 heirs changed since the will was built.
- `_CliBalPlugin`: minimal stand-in exposing only what `bal.core` needs
headless (`WILLEXECUTORS`, `NO_WILLEXECUTOR`, `get_decimal_point`).
**Three real bugs found and fixed while implementing this:**
1. `Will.only_valid()` returns a GENERATOR - `len()` on it raised
`TypeError`; now wrapped in `list()`.
2. `copy.deepcopy(tx)` fails on Electrum 4.8 (`cannot pickle '_thread.RLock'`);
signing now re-parses the transaction from its serialization instead.
3. **Silent will loss on save**: values loaded from the wallet DB are
`StoredDict`s carrying the DB lock, and `JsonDB.put` deep-copies its value
and returns False *silently* when that fails - the will appeared saved but
was never written. Persistence now normalizes through a
`json.dumps`/`loads` round-trip (which also validates serializability) and
exits with an explicit error if anything is non-serializable.
Also added: pre-build validation of the selected executor addresses for the
active network (refreshed from the server when possible), and a guard that
catches exception sentinels embedded by `heirs.py` in a heir entry when an
output cannot be built.
**Verification:**
- New `test_cli_will_cycle` in `tests/test_cli_smoke.py`: creates a REAL
testnet wallet, funds it offline with a handmade transaction, then drives
`build -> check(4) -> sign --yes -> check(5) -> push (aborted at the
confirmation prompt) -> status`. No network access.
- Full suite: **291 passed** against **Electrum 4.7.2** and **4.8.0** (same 2
pre-existing, unrelated `baltx_fees` failures in both).
- `ruff`: all checks passed.
**Note:** repository-only change (CLI + tests). The plugin version is
unchanged; the Qt plugin code is untouched.
**Outcome:** DONE.
---
## 46. v0.6.1 - Version read from manifest.json (single source of truth) - PR #4
**Date:** 2026-07-22
**Goal (Truman):** stop keeping the version in four places (`bal/VERSION`,
`bal/manifest.json`, `bal/__init__.py`, `bal/core/plugin_base.py`) synced by a
pre-commit hook. The version must be read at runtime from `manifest.json` only.
**What changed:**
- `bal/core/plugin_base.py`: new module function `get_version()` reads the
`"version"` field from `bal/manifest.json` using `importlib.resources`
(zip-safe: works both extracted and from inside a zip, which is how Electrum
loads external plugins via `zipimport`; no hand-built paths, so no Windows
backslash-in-zip issue). The value is cached. `BalPlugin` now exposes a
`version` **property** returning `get_version()`; the hardcoded
`__version__` class attribute and the old `version()` method that read the
`VERSION` file are removed.
- The three call sites that printed the version now use the property
(`self.bal_window.bal_plugin.version` in `bal/gui/qt/widgets.py` and
`bal/gui/qt/dialogs.py`) or `get_version()` where no plugin instance is
available (`bal/core/willexecutors.py`, the HTTP user-agent, a static
method).
- **Removed the `bal/VERSION` file** (the plugin package now ships 36 files
instead of 37).
- `HANDOFF.md`: updated the four references that told maintainers to edit
`bal/VERSION` / keep four files in sync - now they point to
`manifest.json` as the single source of truth. (Historical version
references in `CHANGELOG.md` and `.agent_memory_tasks.md` are left intact on
purpose - they describe past events.)
**Verification:**
- New `tests/test_version_source.py` (7 tests): version equals the manifest
field; the `version` property matches `get_version()`; no hardcoded
`__version__` remains; no `bal/VERSION` file remains; the value is cached;
and - the key one - the version is read correctly when `bal` is imported
FROM INSIDE A ZIP in a child interpreter (the real Electrum/Windows
scenario).
- Full suite: **298 passed** against **Electrum 4.7.2** and **4.8.0** (same 2
pre-existing, unrelated `baltx_fees` failures in both).
- `ruff`: clean.
- Also verified the built distribution zip reports the manifest version via
`get_version()` (bumped to 0.6.1 in this change).
**Note:** repository change targeting PR #4 (branch
`bitcoinafterlife-patch-5`). No functional change to inheritance behavior.
**Outcome:** DONE.
---
## 47. Willexecutors: fee-bound `is_valid` (extremes allowed), `is_selected` flag-only
**Date:** 2026-08-01
**Goal:** separate the "is this executor selected" question from the "is its fee
acceptable" question, and let the fee bounds be inclusive at both extremes so an
executor whose fee is *exactly* dust or *exactly* the max fee is valid.
**What changed (`bal/core/willexecutors.py`):**
- `is_selected()` no longer takes a `max_fee` parameter - it only reads/sets the
`"selected"` flag. The previous max-fee cut-off inside `is_selected` rejected
executors with `base_fee >= max_fee` even when they were otherwise valid.
- `is_valid()` bounds changed from strict (`<=`) to inclusive (`<` / `>`):
- `base_fee < dust` is invalid (was `<= dust`), so a fee exactly equal to
dust is now valid.
- `base_fee > max_fee` is invalid (was `>= max_fee`), so a fee exactly equal
to the max fee is now valid.
- Defensive `int(base_fee or 0)` in the fee-range import validation so a missing
or empty `base_fee` no longer raises.
**Verification:**
- New tests in `tests/test_no_willexecutor_karen7.py` (class
`TestNoWillexecutorKaren7`): `is_selected` only reads/sets the flag (setter,
default-False); `is_valid` accepts fee == max and fee == dust, rejects
fee > max and fee < dust, and requires a valid address.
- Existing `build_will` test confirms a fee above max still raises.
**Outcome:** DONE.
---
## 48. Fix `merge_will` crash on missing `date_to_check`
**Date:** 2026-08-01
**Goal:** allow *Will → Merge → Import file* to work as the very first action
of a session, without requiring the normal wizard flow to have run first.
**What changed (`bal/gui/qt/window.py`):**
- `merge_will()` crashed with `AttributeError` when `self.date_to_check` was not
yet set (merge-from-file can run before `init_class_variables()`). It now
falls back to `datetime.now().timestamp()` when the attribute is missing or
`None`, so the local validity check and the trailing `update_all()` always
have a reference timestamp.
- Several `log_error(exec_info, self.bal_window)` calls inside `BalWindow`
methods (broadcast failure, will-building failure) pointed at a wrong object
(`self.bal_window` does not exist on a `BalWindow`); corrected to
`log_error(exec_info, self)` so the error-reporting path itself cannot fail.
**Verification:**
- New tests in `tests/test_import_will_details.py`:
`test_merge_will_missing_date_to_check_defaults_to_now` and
`test_merge_will_validity_error_logs_without_crashing`.
**Outcome:** DONE.

30
COMPATIBILITY.md Normal file
View File

@@ -0,0 +1,30 @@
# Wallet Compatibility
BAL (Bitcoin After Life) builds and signs Electrum transactions using
Electrum's own wallet and signing infrastructure. Its compatibility therefore
depends on the wallet type in use.
| Wallet type | Status | Notes |
|-------------------------------------------------|-----------------------------|-------|
| Standard wallet (single-signature, seed-based) | ✅ Supported | Primary, fully tested target |
| Hardware wallets (Ledger, Trezor, Coldcard, BitBox02, Jade, KeepKey, etc.) | ✅ Supported | Any hardware wallet supported by Electrum itself |
| Multisig wallets | ❌ Not yet supported | Known limitation identified 2026-07-18. Support is planned for a future plugin release. |
| Electrum TrustedCoin (2FA) wallets | ❓ Unknown / unsupported | Known limitation identified 2026-07-18. It has not yet been determined whether or when this will be addressed. |
## What "not supported" means in practice
For multisig and TrustedCoin (2FA) wallets, BAL's behavior has not been
verified and should be considered **unreliable**. Do not rely on BAL to
protect an inheritance set up on one of these wallet types until this document
is updated to mark them as supported.
## Electrum version compatibility
See [`README.md`](README.md) for supported Electrum versions (currently 4.7.2
and 4.8.0).
## Reporting compatibility issues
If you find a compatibility problem not listed here, please open an issue on
this repository describing the wallet type, Electrum version, and the exact
error or unexpected behavior observed.

99
COMPATIBILITY_ROADMAP.md Normal file
View File

@@ -0,0 +1,99 @@
# Compatibility Roadmap: Multisig and TrustedCoin (2FA) Wallets
Status document, 2026-07-19. Companion to [`COMPATIBILITY.md`](COMPATIBILITY.md):
that file states *what* is supported today; this one explains *why* multisig and
TrustedCoin (2FA) wallets are currently unsupported and *how* support can be
added.
## Root cause (common to both)
BAL currently does two things that are only valid for standard
(single-signature) wallets:
1. **It builds transactions itself** with `PartialTransaction.from_io(...)`
(`bal/core/will.py`), bypassing `wallet.make_unsigned_transaction`.
2. **It signs with a single call**`wallet.sign_transaction(tx, password)`
(`bal/gui/qt/window.py`, `sign_transactions`) — and considers the will ready
when `tx.is_complete()` is true.
On a standard wallet one signature completes the transaction. On multisig and
2FA wallets **one signature is not enough**: the transaction stays incomplete,
is never marked `COMPLETE`, and can never be pushed to will-executors.
A secondary single-sig assumption: `plugin.py` uses `wallet.get_keystore()`
(singular); multisig wallets expose `get_keystores()` (plural).
## Multisig wallets — solvable, medium/large effort
A 2-of-3 multisig wallet typically holds **only one** of the required private
keys locally; the other cosigners hold theirs. `wallet.sign_transaction` adds
the local signature only, and BAL has no flow to collect the missing ones.
**Proposed solution: the standard PSBT coordination round** (the same flow
Electrum itself uses for multisig spending):
1. Build and sign locally as today.
2. If the transaction is not complete, **export the partially-signed
transaction(s)** (file and/or QR) and mark the will with a new status such
as `WAITING_COSIGNERS`.
3. Each cosigner signs in their own Electrum (native feature — no new
software needed on their side).
4. BAL **re-imports and merges the signatures**; once complete, the will is
pushed to will-executors as today.
Notes and caveats:
- **Chained will transactions** (a will tx spending the change of a previous
will tx) remain workable: with segwit, the txid of an unsigned/partially
signed transaction is already stable, so the whole chain can be exported as
a batch of PSBTs in one round.
- **Every rebuild requires a new cosigner round.** Check Alive postponements
and balance-change rebuilds re-sign the will, so each of them needs the
cosigners again. This is inherent to multisig and must be clearly
communicated in the UI.
- Implementation surface: export/import/merge pipeline, GUI for it, the new
status in the transaction list, and tests.
Target: **next plugin release**, as announced.
## TrustedCoin (2FA) wallets — harder, with one blocking unknown
An Electrum 2FA wallet (`Wallet_2fa`, defined in Electrum's `trustedcoin`
plugin) is technically a **2-of-3 multisig whose second signer is the
TrustedCoin server**:
- signing requires a **one-time password (OTP) per transaction**
(`server.sign(short_id, raw_tx, otp)`);
- the server co-signs only transactions that include **its billing fee**,
which Electrum adds inside `Wallet_2fa.make_unsigned_transaction` — a code
path BAL currently bypasses (see root cause #1).
So today: no billing output, no OTP prompt, local signature only → incomplete
transaction.
Even with full integration (building via the wallet's
`make_unsigned_transaction`, adding the OTP prompt flow), one **decisive
unknown** remains: will transactions carry a **locktime years in the future**.
Whether the TrustedCoin server agrees to co-sign a transaction with such a
far-future `nLockTime` is an undocumented server-side policy. If it refuses,
2FA support is **not achievable** without TrustedCoin's cooperation. This is
why `COMPATIBILITY.md` marks 2FA as *unknown*.
**Proposed plan:**
1. **Empirical test on testnet** (cheap, decisive): create a test 2FA wallet,
build a far-future-locktime transaction through the proper 2FA path, and
check whether the server signs it.
2. If it signs → implement support: build via `make_unsigned_transaction`
(billing output included), integrate the OTP prompt, and document that
every rebuild costs one OTP round and TrustedCoin fees.
3. If it refuses → document 2FA as unsupported, with the practical
workaround: Electrum allows disabling 2FA by restoring the wallet from the
full seed, which turns it into a standard wallet — fully supported by BAL.
## Recommended order of work
1. **Multisig first**: deterministic path, standard Electrum tooling, already
announced for the next release.
2. **TrustedCoin empirical test in parallel**: low cost, and its outcome
decides whether 2FA support is feasible at all.

View File

@@ -10,7 +10,7 @@
## 0. TL;DR — what this project is ## 0. TL;DR — what this project is
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the - **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
**Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**). **Electrum 4.7.2 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user - **Form:** external **ZIP plugin** (not bundled in Electrum). The user
installs the ZIP from Electrum's plugin manager. installs the ZIP from Electrum's plugin manager.
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast - **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
@@ -19,7 +19,7 @@
services) can be paid a fee to broadcast the inheritance when due. The owner services) can be paid a fee to broadcast the inheritance when due. The owner
periodically proves they are alive ("check-alive"); if the deadline passes, periodically proves they are alive ("check-alive"); if the deadline passes,
the inheritance becomes spendable. the inheritance becomes spendable.
- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**). - **Current version:** see the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`).
--- ---
@@ -55,16 +55,17 @@ These are non-negotiable. They come from the owner directly.
``` ```
bal/ <- the plugin package (this is what ships in the ZIP) bal/ <- the plugin package (this is what ships in the ZIP)
__init__.py <- __version__ (one of 4 version files) __init__.py <- package docstring (no version here anymore)
VERSION <- plain-text version (one of 4 version files) manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
manifest.json <- plugin manifest, "version" field (one of 4) qt.py <- zipimport shim used when loaded as an external ZIP plugin
core/ core/
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4) plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
heirs.py <- HEIRS + transaction building (prepare_lists, heirs.py <- HEIRS + transaction building (prepare_lists,
prepare_transactions, buildTransactions). CORE LOGIC. prepare_transactions, buildTransactions). CORE LOGIC.
will.py <- Will/WillItem, validation (check_amounts, check_will), will.py <- Will/WillItem, validation (check_amounts, check_will),
exceptions (AmountException, WillExpiredException, ...). exceptions (AmountException, WillExpiredException, ...).
willexecutors.py <- remote will-executor services handling. willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
parallel push/check).
util.py <- locktime parsing/most helpers (timestamps only). util.py <- locktime parsing/most helpers (timestamps only).
gui/qt/ gui/qt/
common.py <- shared imports; every gui module does common.py <- shared imports; every gui module does
@@ -72,12 +73,15 @@ bal/ <- the plugin package (this is what ships in the ZI
dialogs.py <- the big build/sign/broadcast dialog dialogs.py <- the big build/sign/broadcast dialog
(BalBuildWillDialog, task_phase1/2), wizard glue. (BalBuildWillDialog, task_phase1/2), wizard glue.
widgets.py <- WillSettingsWidget + wizard widgets/labels. widgets.py <- WillSettingsWidget + wizard widgets/labels.
window.py <- BalWalletWindow (build_will, check_will, get_transactions). window.py <- BalWindow, the per-wallet controller (build_will, check_will,
lists.py, calendar.py, theme.py, window_utils.py, ... get_transactions, merge_will, on_close, menubar wiring).
tests/ <- pytest suite (see run command below). plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
electrum-src/ <- a copy of Electrum source, used ONLY for tests lists.py, calendar.py, theme.py (status colours), window_utils.py
(PYTHONPATH=electrum-src). NOT shipped in the ZIP. wallet_util/ <- standalone wallet-inspection helpers, no Qt
build_zip.py <- builds the shippable ZIP (37 files). tests/ <- standalone test scripts (see Section 3).
docs/ <- user manual + inheritance-options guide (.md sources).
bal_cli.py <- headless CLI (heirs/will build/sign/push/check), no Qt.
build_zip.py <- builds the shippable ZIP (36 files).
CHANGELOG.md <- numbered task log (English). CHANGELOG.md <- numbered task log (English).
.agent_memory_tasks.md <- terse internal memory notes per task batch. .agent_memory_tasks.md <- terse internal memory notes per task batch.
HANDOFF.md <- this file. HANDOFF.md <- this file.
@@ -87,46 +91,72 @@ HANDOFF.md <- this file.
## 3. How to build, test and lint ## 3. How to build, test and lint
Run everything from `/home/user/webapp`. Two separate venvs — using the wrong one is the #1 mistake:
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
`source /home/steal/devel/bal/electrum/env/bin/activate` — an editable
install of the Electrum **4.8.0** checkout at
`/home/steal/devel/bal/electrum`. Use it for anything that imports
`electrum`, runs GUI code, or runs tests. The plugin's `bal/` directory is
symlinked into `electrum/electrum/plugins/bal` (internal-plugin install used
during dev).
- **Lint venv** (repo-local `venv/`): ruff, black, flake8 only. It cannot
import `electrum` or `PyQt6`. Do NOT use it to run tests.
Run everything from `/home/steal/devel/bal/bal-electrum-plugin`.
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
**Full test suite (expected: 266 passed as of v0.4.8):**
```bash ```bash
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \ source /home/steal/devel/bal/electrum/env/bin/activate
tests/test_core_*.py tests/test_gui_*.py \ python3 tests/test_core_heirs.py # core, no Qt needed
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \ QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py -q
``` ```
Most core tests run offline (no wallet/network). Some files
(`tests/test_group_*.py`, `tests/test_no_willexecutor_karen7.py`,
`parallel_ping_test.py`) exercise will-executor/network flows and need the live
servers — don't rely on them for quick verification.
**Current state of the suite: 427 tests collected.** The offline subset passes
(414 passed) apart from pre-existing failures that are NOT yours to fix without
asking: 13 failures in `tests/test_core_will_invalidate.py` (a `None` fee when a
UTXO has no fee value, `bal/core/will.py:482`) and 1 collection error in
`tests/test_group_i_basic_checkalive.py` (missing
`BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS`).
**Lint (only NEW errors matter; ignore pre-existing noise):** **Lint (only NEW errors matter; ignore pre-existing noise):**
```bash ```bash
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \ /home/steal/devel/bal/bal-electrum-plugin/venv/bin/ruff check <files> \
| grep -vE "F401|F403|F405|F841" | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
``` ```
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
`tests/`); do NOT run `--fix` wholesale — just avoid adding new violations.
Pre-existing, KNOWN-OK noise: `F401/F403/F405` (star-imports via
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks). `from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
Do NOT "fix" these unless asked — they are intentional / out of scope. Do NOT "fix" these unless asked — they are intentional / out of scope.
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):** **Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
```bash ```bash
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256; 36 files)
``` ```
**Bump version — there are FOUR files, keep them in sync:** **Bump version — ONE file only (single source of truth):**
``` ```
bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT
bal/__init__.py -> __version__ = "X.Y.Z"
bal/VERSION -> X.Y.Z
bal/manifest.json -> "version": "X.Y.Z", bal/manifest.json -> "version": "X.Y.Z",
``` ```
The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py` (exposed as the `BalPlugin.version` property), so there is nothing else to keep in sync. There is no longer a `bal/VERSION` file nor a hardcoded `__version__`.
**IMPORTANT for the owner when testing:** after installing a ZIP, the owner **IMPORTANT for the owner when testing:** after installing a ZIP, the owner
must **fully restart Electrum** (not just reload the plugin) — Electrum's must **fully restart Electrum** (not just reload the plugin) — Electrum's
`zipimport` caches modules, so a partial reload runs stale code. `zipimport` caches modules, so a partial reload runs stale code.
**Automated release:** use `./make-release.sh` to run the full release flow
(tests, lint, build, GPG sign, SHA-256, Electrum test pause, Gitea release).
See Section 5 for details.
--- ---
## 4. Key technical knowledge (hard-won — saves you hours) ## 4. Key technical knowledge (hard-won — saves you hours)
@@ -225,25 +255,49 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
## 5. Git / delivery workflow ## 5. Git / delivery workflow
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`. - **Branch:** work directly on `main` (no PR flow anymore). Push straight to
`origin/main` (Gitea).
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it - **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
works, THEN commit. (This differs from "commit after every change"; the owner works, THEN commit. (This differs from "commit after every change"; the owner
explicitly prefers ZIP-first because they manually test each build.) explicitly prefers ZIP-first because they manually test each build.)
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve - Before pushing: check `git status`/`git diff`, stage only the intended files
conflicts preferring remote `main` unless a local change is essential, (never secrets), commit with a concise message, then push to `origin/main`.
squash local commits into ONE comprehensive commit, push (force if needed),
then create/update the PR and SHARE the PR URL with the owner.
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are - **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`). distributed via **Gitea Releases** using `make-release.sh`.
The newest release is the "Latest" and is the owner's convenient download. - **Release process** (`make-release.sh`):
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can 1. Version bump in `bal/manifest.json` (single source of truth)
download them directly from chat. 2. Clean `__pycache__` and `.pyc` files
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token", 3. Run full test suite
re-run the GitHub environment setup, then retry. 4. Lint with ruff (skip if not installed)
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section + 5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
translation), **#15** (v0.4.8). All merged into `main`. 6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`); 7. Export public key as `svatantrya.asc`
v0.4.7 kept in history. 8. SHA-256 checksum
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
- **Usage:**
```bash
./make-release.sh # read version from bal/manifest.json
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
```
- **Release assets** (5 files):
- `bal_vX.Y.Z.zip` — the plugin
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
- `svatantrya.asc` — signing public key
- **GPG verification instructions** (included in release body):
```bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
```
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
manifest.json version). All merged into `main`.
- Releases: latest is **v0.6.1**; v0.6.0 and v0.5.18 before it; the older
v0.2.x line is kept in history.
--- ---
@@ -272,6 +326,26 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button (#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`). renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
266 tests pass. 266 tests pass.
- **v0.5.1 — v0.5.10** — BASIC/ADVANCED ("user type") mode work: Windows
settings-dialog flicker fix; Check Alive shown read-only in BASIC; BASIC
builds the will against "now" (`date_to_check = now()`, Check Alive fully
ignored); ADVANCED defaults to RAW (1y/30d); consistent Raw/Date default per
mode; Check Alive red-highlight fixes; clearer "could not build the will"
message; CHECK no longer resets a manual Date/RAW choice.
- **v0.5.11** — Electrum **4.8.0** compatibility (the `json_db.register_dict`
DB-registration API was removed in 4.8; the plugin now supports 4.7.2 and
4.8.0).
- **v0.5.12 — v0.5.18** — Check Alive soft-red highlight removed; short Tor
(.onion) will-executor URLs; KeyError fix on .onion executor actions; skip
.onion executors from download when Electrum is not on Tor; crash fix on a
non-dict welist response; clearer message when the list download
fails/times out over Tor.
- **v0.6.0** — version bump for the official repository release.
- **v0.6.1** — version read from `bal/manifest.json` (single source of truth);
`bal/VERSION` file removed.
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
CHANGELOG).
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail) ### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an - **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
@@ -292,13 +366,16 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
## 7. How to resume (checklist for the next AI) ## 7. How to resume (checklist for the next AI)
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`. 1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
2. Confirm the environment: `git status`, current branch, `bal/VERSION`. 2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`.
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8). 3. Run the offline test files (Section 3) — expect the offline subset to pass
(414 passed; the 13 pre-existing failures in `test_core_will_invalidate.py`
and the 1 collection error in `test_group_i_basic_checkalive.py` are NOT
yours to fix without asking).
4. Talk to the owner in **Italian**, write everything else in **English**. 4. Talk to the owner in **Italian**, write everything else in **English**.
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test, 5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
build a ZIP, let the owner test, and only commit after explicit confirmation. build a ZIP, let the owner test, and only commit after explicit confirmation.
6. Keep credit usage low: summarize, don't paste big code blocks; batch work. 6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`, 7. When the owner confirms a ZIP works: commit (ZIP-first) directly on `main`,
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub push to `origin/main`, then run `./make-release.sh` to create the Gitea
**Release** with the ZIP attached (it becomes the owner's "Latest" download). **Release** with the ZIP + signatures attached (it becomes the owner's
Always give the owner the PR URL and the Release URL. "Latest" download). Always give the owner the Release URL.

View File

@@ -31,17 +31,26 @@ bal/ the installable Electrum plugin package
│ ├── lists.py tree/list views │ ├── lists.py tree/list views
│ ├── window.py per-wallet GUI controller │ ├── window.py per-wallet GUI controller
│ └── plugin.py Plugin (Electrum @hooks → GUI) │ └── plugin.py Plugin (Electrum @hooks → GUI)
├── icons/ wallet_util/ LICENSE VERSION README.md ├── icons/ wallet_util/ LICENSE README.md
build_zip.py builds a clean, zipimport-friendly distribution zip build_zip.py builds a clean, zipimport-friendly distribution zip
tests/ smoke + external-zip regression tests tests/ smoke + external-zip regression tests
``` ```
## Requirements ## Requirements
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`, - **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
which this plugin relies on. Newer versions removed it. registration API is available (`json_db.register_dict` on 4.7.2,
`stored_dict.register_name` on 4.8.0) and adapts automatically.
- **PyQt6** (bundled with the Electrum desktop GUI). - **PyQt6** (bundled with the Electrum desktop GUI).
## Wallet compatibility
BAL currently supports **standard (single-signature) wallets** and
**hardware wallets** supported by Electrum. **Multisig wallets** and
**Electrum TrustedCoin (2FA) wallets** are **not yet supported** — see
[`COMPATIBILITY.md`](COMPATIBILITY.md) for the full compatibility matrix and
current status.
## Installation ## Installation
### Build the distribution archive ### Build the distribution archive
@@ -76,12 +85,17 @@ to broadcast it (they collect fees). Because the locktime is baked into the
signed transaction, simply changing the delivery time later is **not enough**: signed transaction, simply changing the delivery time later is **not enough**:
the old, already-signed transaction keeps living on the will-executors. the old, already-signed transaction keeps living on the will-executors.
The plugin handles the two cases as follows (triggered when you press The plugin handles the cases as follows (triggered when you press
**Tools → Prepare**): **Prepare** on the **WILL** tab):
* **Anticipate** (new delivery time *earlier* than the signed locktime): the * **Anticipate** (new delivery time *earlier* than the signed locktime, still
will is treated as expired and you are asked to **invalidate** the old in the future): a plain **rebuild** the transactions are re-created with
transaction on-chain, then rebuild. the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
even if the will was already signed/sent: moving the date earlier only makes
the inheritance available *sooner*, so there is no early-execution risk.
* **Expire** (new delivery time now in the **past**): the will is genuinely
expired and you are asked to **invalidate** the old transaction on-chain,
then rebuild.
* **Postpone** (new delivery time *later* than the signed locktime) on a will * **Postpone** (new delivery time *later* than the signed locktime) on a will
that was already **signed and/or pushed**: the previously committed coins that was already **signed and/or pushed**: the previously committed coins
must be invalidated on-chain **first**, otherwise a will-executor could must be invalidated on-chain **first**, otherwise a will-executor could
@@ -112,14 +126,15 @@ state.
## Testing ## Testing
Run the tests with the **runtime environment** active (see `HANDOFF.md` §3 for
the two venvs and how to activate them):
```bash ```bash
# imports + behavior # imports + behavior
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \ QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal
python3 tests/smoke_test.py electrum.plugins.bal
# external-zip loading regression # external-zip loading regression (run after build_zip.py)
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \ QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
python3 tests/external_zip_test.py bal-electrum-plugin.zip
``` ```
## ⚠️ Safety ## ⚠️ Safety

View File

@@ -12,8 +12,8 @@ servers.
already-signed will is handled safely. Postponing a signed/sent will first already-signed will is handled safely. Postponing a signed/sent will first
asks you to invalidate the old transaction on-chain (so a will-executor can asks you to invalidate the old transaction on-chain (so a will-executor can
never broadcast the earlier-locktime transaction and execute the inheritance never broadcast the earlier-locktime transaction and execute the inheritance
too early), then lets you rebuild and re-send the new one via too early), then lets you rebuild and re-send the new one via the
**Tools → Prepare**. **Prepare** button on the **WILL** tab.
- **"Server" column**: the will transaction list shows whether each transaction - **"Server" column**: the will transaction list shows whether each transaction
is actually stored on the will-executor servers is actually stored on the will-executor servers
(`Confirmed on server`, `Sent (not checked)`, `Send failed`, (`Confirmed on server`, `Sent (not checked)`, `Send failed`,

View File

@@ -1 +0,0 @@
0.5.18

View File

@@ -36,4 +36,7 @@ The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
available and adapts, so both releases keep working. available and adapts, so both releases keep working.
""" """
__version__ = "0.5.18" # The plugin version is NOT defined here. It lives only in ``bal/manifest.json``
# (the single source of truth) and is read at runtime via ``get_version()`` in
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.

111
bal/core/checkalive.py Normal file
View File

@@ -0,0 +1,111 @@
"""
bal.core.checkalive
===================
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
"""
from datetime import datetime
from typing import Any
from .plugin_base import BalTimestamp
class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def resolve_date_to_check(
is_basic_mode: bool,
will_settings: Any,
now: float | None = None,
built_locktime: float | int | None = None,
) -> float:
"""Return the reference timestamp for every will-validity check.
``date_to_check`` is the single reference timestamp that EVERY downstream
check reads: the build filter, the heir count, ``check_will_expired``,
``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
means "N days BEFORE the delivery date" and is resolved against the stored
locktime (matching the date the settings widget displays), so it stays in
lockstep with the built transactions instead of drifting with the clock.
A RELATIVE stored locktime is resolved against the frozen delivery date of
the built will (``built_locktime``, the locktime inside the signed tx) when
one exists: the will's real delivery date is authoritative, and resolving
the relative locktime from *now* would drift ``date_to_check`` past the
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
invalidate) every day. Without a built will the legacy forward-from-now
resolution is kept.
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
will_settings: the per-wallet settings dict (``"threshold"`` and
``"locktime"`` keys).
now: overridable clock for tests; defaults to ``datetime.now()``.
built_locktime: the absolute locktime frozen inside the built will's
transactions (``None`` when there is no built will yet).
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now().timestamp())
threshold = BalTimestamp(will_settings["threshold"])
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
# the settings widget resolves it as real_threshold = locktime - N days.
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
# date_to_check into a moving target that disagrees with the fixed
# locktime of the built transactions (and with the date shown in the UI),
# which can wrongly mark the will as expired/postponed. Resolve it
# against the delivery date instead.
if threshold.unit is not None:
locktime_raw = will_settings.get("locktime")
if locktime_raw is None:
# No delivery reference to anchor to: fall back to the legacy
# forward-from-now resolution.
return threshold.to_timestamp()
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
# will has already been built, its frozen delivery date (the tx
# locktime) is the authoritative anchor (see docstring).
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
return threshold.to_date(locktime_dt, reverse=True).timestamp()
return threshold.to_timestamp()
def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool:
"""True when the Check Alive guard should fire (``CheckAliveError``).
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone or
rewrite of the will.
"""
if is_basic_mode:
return False
current = now if now is not None else datetime.now().timestamp()
return date_to_check < current

View File

@@ -21,6 +21,8 @@ Will-executor "heirs" are synthetic entries whose key starts with the
``w!ll3x3c"`` marker; they are skipped by most heir comparisons. ``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
""" """
import asyncio
import inspect
import math import math
import random import random
import re import re
@@ -31,6 +33,7 @@ from typing import (
Dict, Dict,
Optional, Optional,
Tuple, Tuple,
cast,
) )
import dns import dns
@@ -65,6 +68,19 @@ if TYPE_CHECKING:
_logger = get_logger(__name__) _logger = get_logger(__name__)
def _query_txt_records(url: str) -> Tuple[Any, bool]:
"""Resolve TXT records with DNSSEC validation.
``electrum.dnssec.query`` became an ``async`` function in Electrum 4.7.2,
so adapt the call for this synchronous context while staying compatible
with older synchronous implementations.
"""
query = dnssec.query
if inspect.iscoroutinefunction(query):
return asyncio.run(query(url, dns.rdatatype.TXT))
return cast(Tuple[Any, bool], query(url, dns.rdatatype.TXT))
# Column layout of a stored heir list. These indices are part of the on-disk # Column layout of a stored heir list. These indices are part of the on-disk
# wallet format and are relied upon all over the codebase, so they must NEVER # wallet format and are relied upon all over the codebase, so they must NEVER
# be reordered. # be reordered.
@@ -75,6 +91,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...") HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
TRANSACTION_LABEL = "inheritance transaction" TRANSACTION_LABEL = "inheritance transaction"
OP_RETURN_PREFIX = "OP_RETURN:"
class AliasNotFoundException(Exception): class AliasNotFoundException(Exception):
pass pass
@@ -86,6 +104,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
output.value = math.floor((in_amount - fee) / out_amount * output.value) output.value = math.floor((in_amount - fee) / out_amount * output.value)
def is_op_return_address(address: str) -> bool:
return str(address).startswith(OP_RETURN_PREFIX)
def get_op_return_hex(address: str) -> Optional[str]:
if is_op_return_address(address):
return address[len(OP_RETURN_PREFIX):]
return None
def validate_op_return_hex(data_hex: str) -> None:
try:
data = bytes.fromhex(data_hex)
except ValueError:
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
if len(data) > 80:
raise NotAnAddress(
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
)
def create_op_return_script(data_hex: str) -> bytes: def create_op_return_script(data_hex: str) -> bytes:
"""Crea scriptpubkey OP_RETURN in bytes""" """Crea scriptpubkey OP_RETURN in bytes"""
data = bytes.fromhex(data_hex) data = bytes.fromhex(data_hex)
@@ -132,6 +171,14 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
heir[HEIR_REAL_AMOUNT] heir[HEIR_REAL_AMOUNT]
): ):
try: try:
if is_op_return_address(heir[HEIR_ADDRESS]):
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
op_return_script = create_op_return_script(data_hex)
outputs.append(
PartialTxOutput(value=0, scriptpubkey=op_return_script)
)
description += f"{name}\n"
else:
real_amount = heir[HEIR_REAL_AMOUNT] real_amount = heir[HEIR_REAL_AMOUNT]
outputs.append( outputs.append(
PartialTxOutput.from_address_and_value( PartialTxOutput.from_address_and_value(
@@ -174,7 +221,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
change = get_change_output(wallet, in_amount, out_amount, fee) change = get_change_output(wallet, in_amount, out_amount, fee)
if change: if change:
outputs.append(change) outputs.append(change)
for i in range(0, 100): for _ in range(0, 100):
random.shuffle(outputs) random.shuffle(outputs)
#op_return_text = "Hello Bal!" #op_return_text = "Hello Bal!"
@@ -230,6 +277,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
# TODO calculate de minimum inputs to be invalidated # TODO calculate de minimum inputs to be invalidated
def invalidate_inheritance_transactions(wallet): def invalidate_inheritance_transactions(wallet):
_logger.debug("invalidate tx in heir method")
# listids = [] # listids = []
utxos = {} utxos = {}
dtxs = {} dtxs = {}
@@ -249,7 +297,7 @@ def invalidate_inheritance_transactions(wallet):
del dtxs[txid] del dtxs[txid]
utxos = {} utxos = {}
for txid, tx in dtxs.items(): for _, tx in dtxs.items():
get_utxos_from_inputs(tx.inputs(), tx, utxos) get_utxos_from_inputs(tx.inputs(), tx, utxos)
utxos = sorted(utxos.items(), key=lambda item: len(item[1])) utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
@@ -264,39 +312,6 @@ def invalidate_inheritance_transactions(wallet):
remaining[key] = value remaining[key] = value
def print_transaction(heirs, tx, locktimes, tx_fees):
jtx = tx.to_json()
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
print("---")
for inp in jtx["inputs"]:
print(f"{inp['address']}: {inp['value_sats']}")
print("---")
for out in jtx["outputs"]:
heirname = ""
for key in heirs.keys():
heir = heirs[key]
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
jtx["locktime"]
):
heirname = key
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
print()
size = tx.estimated_size()
print(
"fee: {}\texpected: {}\tsize: {}".format(
tx.input_value() - tx.output_value(), size * tx_fees, size
)
)
print()
try:
print(tx.serialize_to_network())
except Exception:
print("impossible to serialize")
print()
def get_change_output(wallet, in_amount, out_amount, fee): def get_change_output(wallet, in_amount, out_amount, fee):
change_amount = int(in_amount - out_amount - fee) change_amount = int(in_amount - out_amount - fee)
if change_amount > wallet.dust_threshold(): if change_amount > wallet.dust_threshold():
@@ -400,6 +415,9 @@ class Heirs(dict, Logger):
amount = 0 amount = 0
for key, v in heir_list.items(): for key, v in heir_list.items():
try: try:
if is_op_return_address(v[HEIR_ADDRESS]):
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
continue
column = HEIR_AMOUNT column = HEIR_AMOUNT
if real: if real:
column = HEIR_REAL_AMOUNT column = HEIR_REAL_AMOUNT
@@ -451,6 +469,12 @@ class Heirs(dict, Logger):
) )
) )
continue continue
if is_op_return_address(self[key][HEIR_ADDRESS]):
heir = list(self[key])
heir.insert(HEIR_REAL_AMOUNT, 0)
fixed_heirs[key] = heir
_logger.debug(f"OP_RETURN heir {key} excluded from amount calculation")
continue
if Util.is_perc(self[key][HEIR_AMOUNT]): if Util.is_perc(self[key][HEIR_AMOUNT]):
percent_amount += float(self[key][HEIR_AMOUNT][:-1]) percent_amount += float(self[key][HEIR_AMOUNT][:-1])
percent_heirs[key] = list(self[key]) percent_heirs[key] = list(self[key])
@@ -478,7 +502,8 @@ class Heirs(dict, Logger):
) )
def prepare_lists( def prepare_lists(
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0 self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
from_locktime=0, max_fee=None,
): ):
if balance<total_fees or balance < wallet.dust_threshold(): if balance<total_fees or balance < wallet.dust_threshold():
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees) raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
@@ -493,8 +518,12 @@ class Heirs(dict, Logger):
if int(Util.int_locktime(locktime)) > int(from_locktime): if int(Util.int_locktime(locktime)) > int(from_locktime):
try: try:
base_fee = int(willexecutor["base_fee"]) base_fee = int(willexecutor["base_fee"])
if max_fee is not None and base_fee > max_fee:
raise WillExecutorFeeTooHighException(
willexecutor, max_fee
)
willexecutors_amount += base_fee willexecutors_amount += base_fee
h = [None] * 4 h: list = [None] * 4
h[HEIR_AMOUNT] = base_fee h[HEIR_AMOUNT] = base_fee
h[HEIR_REAL_AMOUNT] = base_fee h[HEIR_REAL_AMOUNT] = base_fee
h[HEIR_LOCKTIME] = locktime h[HEIR_LOCKTIME] = locktime
@@ -586,6 +615,8 @@ class Heirs(dict, Logger):
heir[HEIR_REAL_AMOUNT] heir[HEIR_REAL_AMOUNT]
): ):
valid_real_heirs += 1 valid_real_heirs += 1
elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]):
valid_real_heirs += 1
if real_heirs > 0 and valid_real_heirs == 0: if real_heirs > 0 and valid_real_heirs == 0:
raise HeirAmountIsDustException( raise HeirAmountIsDustException(
"All heirs' shares are below the dust limit" "All heirs' shares are below the dust limit"
@@ -599,7 +630,19 @@ class Heirs(dict, Logger):
def buildTransactions( def buildTransactions(
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0 self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
): ):
Heirs._validate(self) _before = list(self.keys())
Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self]
if _removed:
# The build skips invalid heirs, but they are only dropped in memory
# (persist=False): the wallet still keeps them, so the user can fix
# or remove them deliberately instead of losing them silently.
_logger.warning(
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
"not removed): %s",
len(_removed),
", ".join(_removed),
)
if len(self) <= 0: if len(self) <= 0:
_logger.info("while building transactions there was no heirs") _logger.info("while building transactions there was no heirs")
return return
@@ -612,7 +655,7 @@ class Heirs(dict, Logger):
self.decimal_point = bal_plugin.get_decimal_point() self.decimal_point = bal_plugin.get_decimal_point()
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get() no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
for utxo in utxos: for utxo in utxos:
if utxo.value_sats() > 0 * tx_fees: if utxo.value_sats() > 0:
balance += utxo.value_sats() balance += utxo.value_sats()
len_utxo_set += 1 len_utxo_set += 1
available_utxos.append(utxo) available_utxos.append(utxo)
@@ -629,18 +672,19 @@ class Heirs(dict, Logger):
break break
elif 0 <= j: elif 0 <= j:
url, willexecutor = willexecutorsitems[j] url, willexecutor = willexecutorsitems[j]
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold(): if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
continue continue
else: else:
willexecutor["url"] = url willexecutor["url"] = url
elif j == -1: elif j == -1:
if not no_willexecutors: if not no_willexecutors:
continue continue
url = willexecutor = False url = willexecutor = None
else: else:
break break
fees = {} fees = {}
i = 0 i = 0
txs = {}
while i < 10: while i < 10:
txs = {} txs = {}
redo = False redo = False
@@ -651,11 +695,15 @@ class Heirs(dict, Logger):
# newbalance = balance # newbalance = balance
try: try:
locktimes, onlyfixed = self.prepare_lists( locktimes, onlyfixed = self.prepare_lists(
balance, total_fees, wallet, willexecutor, from_locktime balance, total_fees, wallet, willexecutor, from_locktime,
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except WillExecutorFeeException: except WillExecutorFeeException:
i = 10 i = 10
continue continue
except WillExecutorFeeTooHighException:
i = 10
continue
if locktimes: if locktimes:
try: try:
txs = prepare_transactions( txs = prepare_transactions(
@@ -674,7 +722,7 @@ class Heirs(dict, Logger):
) )
break break
except Exception: except Exception:
raise e raise
total_fees = 0 total_fees = 0
total_fees_real = 0 total_fees_real = 0
total_in = 0 total_in = 0
@@ -776,7 +824,7 @@ class Heirs(dict, Logger):
# support email-style addresses, per the OA standard # support email-style addresses, per the OA standard
url = url.replace("@", ".") url = url.replace("@", ".")
try: try:
records, validated = dnssec.query(url, dns.rdatatype.TXT) records, validated = _query_txt_records(url)
except DNSException as e: except DNSException as e:
_logger.info(f"Error resolving openalias: {repr(e)}") _logger.info(f"Error resolving openalias: {repr(e)}")
return None return None
@@ -785,60 +833,78 @@ class Heirs(dict, Logger):
string = to_string(record.strings[0], "utf8") string = to_string(record.strings[0], "utf8")
if string.startswith("oa1:" + prefix): if string.startswith("oa1:" + prefix):
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)") address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
if not address:
continue
name = cls.find_regex(string, r"recipient_name=([^;]+)") name = cls.find_regex(string, r"recipient_name=([^;]+)")
if not name: if not name:
name = address name = address
if not address:
continue
return address, name, validated return address, name, validated
@staticmethod @staticmethod
def find_regex(haystack, needle): def find_regex(haystack, needle) -> Optional[str]:
regex = re.compile(needle) regex = re.compile(needle)
try: try:
return regex.search(haystack).groups()[0] return regex.search(haystack).groups()[0]
except AttributeError: except AttributeError:
return None return None
@staticmethod
def validate_address(address): def validate_address(address):
if is_op_return_address(address):
data_hex = address[len(OP_RETURN_PREFIX):]
validate_op_return_hex(data_hex)
return address
if not bitcoin.is_address(address, net=constants.net): if not bitcoin.is_address(address, net=constants.net):
raise NotAnAddress(f"not an address,{address}") raise NotAnAddress(f"not an address,{address}")
return address return address
@staticmethod
def validate_amount(amount): def validate_amount(amount):
try: try:
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount) famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
if famount <= 0.00000001: if famount <= 0.00000001:
raise AmountNotValid(f"amount have to be positive {famount} < 0") raise AmountNotValid(f"amount have to be positive {famount} < 0")
except Exception as e: except Exception as e:
raise AmountNotValid(f"amount not properly formatted, {e}") raise AmountNotValid(f"amount not properly formatted, {e}") from e
return amount return amount
@staticmethod
def validate_locktime(locktime, timestamp_to_check=False): def validate_locktime(locktime, timestamp_to_check=False):
try: try:
if timestamp_to_check: if timestamp_to_check:
if Util.parse_locktime_string(locktime, None) < timestamp_to_check: if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
raise HeirExpiredException() raise HeirExpiredException()
except Exception as e: except Exception as e:
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
return locktime return locktime
@staticmethod
def validate_heir(k, v, timestamp_to_check=False): def validate_heir(k, v, timestamp_to_check=False):
address = Heirs.validate_address(v[HEIR_ADDRESS]) address = Heirs.validate_address(v[HEIR_ADDRESS])
if is_op_return_address(v[HEIR_ADDRESS]):
amount = "0"
else:
amount = Heirs.validate_amount(v[HEIR_AMOUNT]) amount = Heirs.validate_amount(v[HEIR_AMOUNT])
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check) locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
return (address, amount, locktime) return (address, amount, locktime)
def _validate(data, timestamp_to_check=False): @staticmethod
def _validate(data, timestamp_to_check=False, persist=True):
for k, v in list(data.items()): for k, v in list(data.items()):
if k == "heirs": if k == "heirs":
return Heirs._validate(v, timestamp_to_check) return Heirs._validate(v, timestamp_to_check, persist)
try: try:
Heirs.validate_heir(k, v, timestamp_to_check) Heirs.validate_heir(k, v, timestamp_to_check)
except Exception as e: except Exception as e:
_logger.info(f"exception heir removed {e}") _logger.info(f"exception heir removed {e}")
if persist:
data.pop(k) data.pop(k)
else:
# Drop the invalid heir in memory only, so the overridden
# Heirs.pop (which calls save()) is not triggered: a build
# must not silently delete heirs from the wallet.
dict.pop(data, k)
return data return data
@@ -874,6 +940,19 @@ class WillExecutorFeeException(Exception):
return "WillExecutorFeeException: {} fee:{}".format( return "WillExecutorFeeException: {} fee:{}".format(
self.willexecutor["url"], self.willexecutor["base_fee"] self.willexecutor["url"], self.willexecutor["base_fee"]
) )
class WillExecutorFeeTooHighException(Exception):
def __init__(self, willexecutor, max_fee):
self.willexecutor = willexecutor
self.max_fee = max_fee
def __str__(self):
return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format(
self.willexecutor["url"],
self.willexecutor["base_fee"],
self.max_fee,
)
class BalanceTooLowException(Exception): class BalanceTooLowException(Exception):
def __init__(self,balance, dust_threshold, fees): def __init__(self,balance, dust_threshold, fees):
self.balance=balance self.balance=balance

193
bal/core/input_rules.py Normal file
View File

@@ -0,0 +1,193 @@
"""
bal.core.input_rules
====================
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
amount field normalisation.
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
Keeping them here makes them testable without Qt and lets any GUI front-end
reuse the exact same parsing rules.
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional, Tuple, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
__all__ = [
"NLOCKTIME_BLOCKHEIGHT_MAX",
"NLOCKTIME_MAX",
"NLOCKTIME_MIN",
"LockTimeEditor",
"normalize_locktime_raw_text",
"normalize_perc_amount_text",
"parse_perc_amount",
"replace_dy_suffixes",
]
class LockTimeEditor:
"""Acceptance bounds shared by the RAW and Date locktime editors.
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
live here so they can be tested and reused without Qt. Widget subclasses
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
"""
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
"""True when ``x`` (string or int) is within the allowed locktime bounds.
An empty/falsy value is accepted (the field is not yet filled in).
"""
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
"""Highest locktime timestamp accepted on this platform.
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
"""
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is
# platform-dependent). see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
def replace_dy_suffixes(text: str) -> str:
"""Strip the relative-time suffixes (d/y) from ``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(s: str, pos: int, appendix: str) -> Tuple[int, str]:
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
When the character just before ``pos`` equals ``appendix``, the text is
re-normalised so only one suffix remains.
"""
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = replace_dy_suffixes(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def normalize_locktime_raw_text(
text: str, pos: int
) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text.
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
Args:
text: the raw field text.
pos: the cursor position within ``text``.
Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
text and ``new_pos`` the adjusted cursor position.
"""
text = text.strip()
chars = "0123456789dy"
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
isdays = False
isyears = False
pos, s = _checkbdy(s, pos, "d")
pos, s = _checkbdy(s, pos, "y")
if "d" in s:
isdays = True
if "y" in s:
isyears = True
if isdays:
s = replace_dy_suffixes(s) + "d"
if isyears:
s = replace_dy_suffixes(s) + "y"
return s, isdays, isyears, pos
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
"""Sanitise the amount-or-percentage field text.
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
as a percentage (``is_perc``). At most 8 decimal digits after the point.
Args:
text: the raw field text.
decimal_point: the decimal separator character (``electrum`` uses
``DECIMAL_POINT``, locale dependent).
Returns:
``(clean, is_perc)``.
"""
text = text.strip()
chars = "0123456789%"
chars += decimal_point
s = "".join([i for i in text if i in chars])
if "%" in s:
is_perc = True
s = s.replace("%", "")
else:
is_perc = False
if decimal_point in s:
p = s.find(decimal_point)
s = s.replace(decimal_point, "")
s = s[:p] + decimal_point + s[p : p + 8]
if is_perc:
s += "%"
return s, is_perc
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
"""Parse an amount-or-percentage field text into a numeric value.
Returns ``None`` when the text cannot be parsed.
"""
try:
text = text.replace(decimal_point, ".")
text = text.replace("%", "")
return Decimal(text)
except Exception:
return None

View File

@@ -21,6 +21,7 @@ serialised together with the wallet file.
This module performs **no** GUI work and imports nothing from PyQt / electrum.gui. This module performs **no** GUI work and imports nothing from PyQt / electrum.gui.
""" """
import json
import os import os
import platform import platform
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
@@ -33,6 +34,46 @@ from electrum.transaction import tx_from_any
_logger = get_logger(__name__) _logger = get_logger(__name__)
# --------------------------------------------------------------------------- #
# Plugin version - single source of truth
# --------------------------------------------------------------------------- #
# The version lives ONLY in bal/manifest.json (the file Electrum itself reads).
# We used to hardcode it in four files and keep them in sync with a pre-commit
# hook; reading it from the manifest removes that duplication.
#
# importlib.resources is used on purpose: it reads a data file bundled inside
# the ``bal`` package and works identically whether the plugin runs from an
# extracted directory or from INSIDE a zip (Electrum loads external plugins via
# zipimport). It never builds a path by hand, so there is no os.path.join
# backslash issue on Windows inside a zip.
_VERSION_CACHE = None
def get_version():
"""Return the plugin version from ``bal/manifest.json`` (cached).
Zip-safe and independent of the current working directory. Falls back to
``"unknown"`` if the manifest cannot be read, so importing the plugin never
fails just because of version lookup.
"""
global _VERSION_CACHE
if _VERSION_CACHE is None:
try:
import importlib.resources
_parent_pkg = __package__.rpartition(".")[0] if __package__ else "bal"
data = (
importlib.resources.files(_parent_pkg)
.joinpath("manifest.json")
.read_text(encoding="utf-8")
)
_VERSION_CACHE = json.loads(data)["version"]
except Exception as e: # noqa: BLE001 - never break import over version
_logger.error(f"failed to read version from manifest.json: {e}")
_VERSION_CACHE = "unknown"
return _VERSION_CACHE
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Wallet-DB registration # Wallet-DB registration
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -67,7 +108,7 @@ def get_will(x):
try: try:
# Electrum >= 4.8.0 # Electrum >= 4.8.0
from electrum.stored_dict import register_name as _electrum_register_name from electrum.stored_dict import register_name as _electrum_register_name # pyright: ignore[reportMissingImports]
def _register_will_dict(name, method, _type=None): def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API).""" """Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
@@ -77,7 +118,7 @@ except ImportError:
# Electrum <= 4.7.2 # Electrum <= 4.7.2
def _register_will_dict(name, method, _type=None): def _register_will_dict(name, method, _type=None):
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API).""" """Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
json_db.register_dict(name, method, _type) json_db.register_dict(name, method, _type) # pyright: ignore[reportAttributeAccessIssue]
_register_will_dict("heirs", tuple) _register_will_dict("heirs", tuple)
@@ -120,9 +161,6 @@ class BalPlugin(BasePlugin):
layer (or unit tests) can use the plugin logic without importing Qt. layer (or unit tests) can use the plugin logic without importing Qt.
""" """
_version = None
__version__ = "0.5.18" # 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 = {
"Linux": "xdg-open", "Linux": "xdg-open",
@@ -138,18 +176,11 @@ class BalPlugin(BasePlugin):
# Default geometry hint for some dialogs (kept from the original code). # Default geometry hint for some dialogs (kept from the original code).
SIZE = (159, 97) SIZE = (159, 97)
@property
def version(self): def version(self):
"""Return the plugin version, read once from the ``VERSION`` file.""" """Plugin version, read from ``bal/manifest.json`` (single source of
if not self._version: truth). See :func:`get_version`."""
try: return get_version()
f = ""
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
f = str(fi.read())
self._version = f.strip()
except Exception as e:
_logger.error(f"failed to get version: {e}")
self._version = "unknown"
return self._version
def __init__(self, parent, config, name): def __init__(self, parent, config, name):
self.logger = get_logger(__name__) self.logger = get_logger(__name__)
@@ -197,6 +228,21 @@ 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)
# SAVE_HISTORY (history persistence): when enabled, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after every check, each with a configurable label. Default ON.
self.SAVE_HISTORY = BalConfig(config, "bal_save_history", True)
# HISTORY_LABEL: label text applied to the will transactions saved into
# the wallet's local history. May contain the "{willexecutor}" token,
# which is replaced with the will-executor URL of each will item at
# save time.
self.HISTORY_LABEL = BalConfig(
config,
"bal_history_label",
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
# AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after # AUTO_SIGN (Group B / B2): when enabled, pressing "Check" will, after
# querying the will-executor servers, automatically sign the will # querying the will-executor servers, automatically sign the will
# transactions and broadcast them to their will-executors, without the # transactions and broadcast them to their will-executors, without the
@@ -228,6 +274,9 @@ class BalPlugin(BasePlugin):
# follows what is saved in that wallet (the default only applies when no # follows what is saved in that wallet (the default only applies when no
# value has been stored yet, i.e. new wallets). # value has been stored yet, i.e. new wallets).
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False) self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
self.MAX_WILLEXECUTOR_FEE = BalConfig(
config, "bal_max_willexecutor_fee", 500000
)
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)
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True) self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
@@ -257,7 +306,10 @@ class BalPlugin(BasePlugin):
config, "bal_event_summary", "BAL -Will execution of $wallet_name" config, "bal_event_summary", "BAL -Will execution of $wallet_name"
) )
# Default will-executor servers, keyed by network. # Default will-executor servers, keyed by network. These addresses are
# the ones currently reported by each server's <chain>/info endpoint and
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
# (bcrt1...) addresses, which are invalid on those networks.
self.WILLEXECUTORS = BalConfig( self.WILLEXECUTORS = BalConfig(
config, config,
"bal_willexecutors", "bal_willexecutors",
@@ -276,7 +328,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000, "base_fee": 100000,
"status": "New", "status": "New",
"info": "Bitcoin After Life Will Executor", "info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True, "selected": True,
} }
}, },
@@ -285,7 +337,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000, "base_fee": 100000,
"status": "New", "status": "New",
"info": "Bitcoin After Life Will Executor", "info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7", "address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"selected": True, "selected": True,
} }
}, },
@@ -356,7 +408,7 @@ class BalPlugin(BasePlugin):
"""Fill in any missing will-setting with its default value.""" """Fill in any missing will-setting with its default value."""
defaults = BalPlugin.default_will_settings() defaults = BalPlugin.default_will_settings()
if not will_settings: if not will_settings:
will_settings = [] will_settings = {}
if int(will_settings.get("baltx_fees", 0)) < 1: if int(will_settings.get("baltx_fees", 0)) < 1:
will_settings["baltx_fees"] = defaults['baltx_fees'] will_settings["baltx_fees"] = defaults['baltx_fees']
if not will_settings.get("threshold"): if not will_settings.get("threshold"):
@@ -378,7 +430,7 @@ class BalPlugin(BasePlugin):
@staticmethod @staticmethod
def default_will_settings(): def default_will_settings():
"""Default will settings: a fee rate plus absolute threshold/locktime.""" """Default will settings: a fee rate plus absolute threshold/locktime."""
will_settings = {"baltx_fees": 20} will_settings: dict[str, float] = {"baltx_fees": 20}
will_settings.update(BalPlugin.default_will_settings_absolute()) will_settings.update(BalPlugin.default_will_settings_absolute())
return will_settings return will_settings
@@ -411,10 +463,12 @@ class BalTimestamp:
* an integer -> an absolute UNIX timestamp (``unit is None``) * an integer -> an absolute UNIX timestamp (``unit is None``)
""" """
value = None value: int
unit = None unit: str | None
def __init__(self, value): def __init__(self, value):
self.value = 1
self.unit = None
str_value = str(value) str_value = str(value)
if str_value and str_value[-1].lower() in ("y", "d"): if str_value and str_value[-1].lower() in ("y", "d"):
self.value = int(str_value[:-1]) self.value = int(str_value[:-1])
@@ -443,14 +497,14 @@ class BalTimestamp:
We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own We clamp out-of-range timestamps to INT32_MAX, mirroring Electrum's own
``get_max_allowed_timestamp`` workaround (see Electrum issue #6170). ``get_max_allowed_timestamp`` workaround (see Electrum issue #6170).
""" """
INT32_MAX = 2 ** 31 - 1 int32_max = 2 ** 31 - 1
try: try:
return datetime.fromtimestamp(ts) return datetime.fromtimestamp(ts)
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
try: try:
return datetime.fromtimestamp(min(int(ts), INT32_MAX)) return datetime.fromtimestamp(min(int(ts), int32_max))
except (OSError, OverflowError, ValueError): except (OSError, OverflowError, ValueError):
return datetime.fromtimestamp(INT32_MAX) return datetime.fromtimestamp(int32_max)
def to_date(self, from_date=None, reverse=False): def to_date(self, from_date=None, reverse=False):
"""Resolve to a ``datetime``. """Resolve to a ``datetime``.

228
bal/core/reminders.py Normal file
View File

@@ -0,0 +1,228 @@
"""
bal.core.reminders
==================
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
iCalendar (.ics) document.
Everything in this module is stdlib-only, so it can be imported and tested
without Electrum or Qt (e.g. in the lint venv).
"""
import os
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Optional
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
def format_time(time) -> str:
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def fold_ical_line(line: str, limit: int = 75) -> str:
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
multi-byte UTF-8 characters. Continuation lines start with a space."""
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# cut without splitting a UTF-8 continuation byte
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)
def ical_escape(text: str) -> str:
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
def write_temp_ics(content: str) -> str:
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
def build_ics_reminders(
*,
locktime: datetime,
basic_mode: bool,
description: str,
summary: str,
wallet_name: str,
heirs_details: str,
version: str,
num_reminders: int = 3,
now: Optional[datetime] = None,
threshold: Optional[datetime] = None,
) -> Optional[str]:
"""Build the ``.ics`` content with one VEVENT per reminder date.
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
user sees several distinct appointments in their calendar. The reminder
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
period).
Args:
locktime: the delivery deadline (datetime).
basic_mode: use the fixed BASIC offsets instead of spreading over the
check-alive period.
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
``$heirs_complete`` placeholders are substituted and escaped.
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
wallet_name: label used in the UID and template substitutions.
heirs_details: pre-formatted heir list injected into ``description``.
version: plugin version, embedded in the PRODID line.
num_reminders: requested reminder count (ADVANCED mode only).
now: "today" reference; defaults to ``datetime.now()``.
threshold: check-alive date (ADVANCED mode only; required there).
Returns:
The ``.ics`` content string, or ``None`` when no reminder falls in the
future (the delivery date is too close or already passed) so the caller
can show a warning instead of producing an empty-looking file.
"""
now = now if now is not None else datetime.now()
if basic_mode:
days_to_deadline = (locktime - now).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
if threshold is None:
raise ValueError("threshold is required in ADVANCED mode")
days = (locktime - threshold).days
offsets = compute_reminder_offsets(days, num_reminders)
# ToDo #2: no future reminder means there are no events to write. Return
# None so the caller shows a clear warning instead of an empty .ics file.
if not offsets:
return None
event_description = ical_escape(
f"{description}"
.replace("$wallet_name", str(wallet_name))
.replace("$heirs_complete", heirs_details)
)
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(wallet_name)}-{offset}d",
f"DTSTAMP:{format_time(now)}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{event_summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"

View File

@@ -20,6 +20,7 @@ original implementation.
import bisect import bisect
from datetime import datetime, timedelta from datetime import datetime, timedelta
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.transaction import PartialTxOutput 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
@@ -130,6 +131,72 @@ class Util:
+ days * 60 * 60 * 24 + days * 60 * 60 * 24
) )
@staticmethod
def _relative_days(value):
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
Returns ``None`` when the value is not a relative recipe (an absolute
timestamp, a plain number, or garbage).
"""
s = str(value)
if s and s[-1] in "yYdD":
try:
n = int(s[:-1])
except ValueError:
return None
return n * 365 if s[-1] in "yY" else n
return None
@staticmethod
def resolve_locktime_against_tx(current, built, tx_locktime):
"""Resolve a locktime recipe against the moment the signed tx was built.
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
target: parsing it against *now* on every check drifts it one day per
day away from the fixed locktime frozen inside the signed Bitcoin
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
plugin asks to invalidate it every day (reported bug). This resolves
the current recipe against the build moment instead, recovered from the
signed transaction's locktime and the recipe that was actually frozen
at build time (``built``, the value stored in the will item):
build_moment = tx_locktime - duration(built)
expected = build_moment + duration(current)
An unchanged recipe therefore resolves to exactly ``tx_locktime``
(coherent), a lengthened one resolves later (postpone) and a shortened
one earlier (anticipate).
Args:
current: the current locktime recipe (relative or absolute).
built: the recipe frozen at build time (stored in the will item).
tx_locktime: the absolute locktime frozen inside the signed tx.
Returns:
int: the resolved absolute locktime (UNIX timestamp).
"""
current_days = Util._relative_days(current)
built_days = Util._relative_days(built)
if current_days is None:
# Absolute current date: compare directly against the frozen tx.
try:
return int(current)
except Exception:
return Util.parse_locktime_string(current)
if built_days is None or not tx_locktime:
# The stored recipe was absolute (a fixed date) or the tx has no
# usable locktime: there is no relative anchor to recover the build
# moment, so fall back to the legacy forward-from-now resolution.
return Util.parse_locktime_string(current)
try:
base = datetime.fromtimestamp(int(tx_locktime)).replace(
hour=0, minute=0, second=0, microsecond=0
)
build_moment = base - timedelta(days=built_days)
return int((build_moment + timedelta(days=current_days)).timestamp())
except Exception:
return Util.parse_locktime_string(current)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Amount helpers # Amount helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -400,14 +467,14 @@ class Util:
def get_lowest_valid_tx(available_utxos, will): def get_lowest_valid_tx(available_utxos, will):
"""Placeholder kept from the original code (sorts the will by locktime).""" """Placeholder kept from the original code (sorts the will by locktime)."""
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime) will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
for txid, willitem in will.items(): for _txid, _willitem in will.items():
pass pass
@staticmethod @staticmethod
def get_locktimes(will): def get_locktimes(will):
"""Return the distinct locktimes used by the transactions in ``will``.""" """Return the distinct locktimes used by the transactions in ``will``."""
locktimes = {} locktimes = {}
for txid, willitem in will.items(): for _, willitem in will.items():
locktimes[willitem["tx"].locktime] = True locktimes[willitem["tx"].locktime] = True
return locktimes.keys() return locktimes.keys()
@@ -446,7 +513,7 @@ class Util:
def get_will_spent_utxos(will): def get_will_spent_utxos(will):
"""Collect every input spent by any transaction in ``will``.""" """Collect every input spent by any transaction in ``will``."""
utxos = [] utxos = []
for txid, willitem in will.items(): for _, willitem in will.items():
utxos += willitem["tx"].inputs() utxos += willitem["tx"].inputs()
return utxos return utxos
@@ -493,6 +560,106 @@ class Util:
return True return True
return False return False
@staticmethod
def get_available_utxos(wallet, history_label, will_locktime=None):
"""Return the wallet's UTXOs as seen by the plugin's flows.
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
marks as spent. The plugin itself creates such local spenders when it
saves an incomplete will transaction into the local history; a *later*
will transaction stored there (a replacement/future will with a locktime
strictly after ``will_locktime``) must not hide the coins from the will
being checked or rebuilt. This view therefore restores those coins.
A local spender is ignored (the coin is kept available) only when ALL of
these hold:
* it is a wallet-local or future transaction (not broadcast),
* its wallet label matches the BAL history label template (after the
"{willexecutor}" substitution),
* the stored spender's locktime is strictly LATER than ``will_locktime``.
Real (broadcast/confirmed) spenders are never ignored. With a falsy
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
Args:
wallet: The Electrum wallet object.
history_label: The BAL history label template (may contain
"{willexecutor}").
will_locktime: Reference locktime of the will being operated on.
"""
if not wallet or not will_locktime:
return list(wallet.get_utxos()) if wallet else []
adb = getattr(wallet, "adb", None)
if adb is None or not hasattr(adb, "get_addr_outputs"):
return list(wallet.get_utxos())
addresses = (
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
)
utxos = []
for addr in addresses:
try:
outputs = adb.get_addr_outputs(addr)
except Exception:
continue
for utxo in outputs.values():
if utxo.spent_height is None:
utxos.append(utxo)
continue
spender = getattr(utxo, "spent_txid", None)
if spender and Util._is_ignorable_local_spender(
wallet, spender, history_label, will_locktime
):
utxos.append(utxo)
return utxos
@staticmethod
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
"""True when the local ``spender`` tx is a later BAL history will tx.
See ``get_available_utxos`` for the exact conditions. Defensive: any
lookup failure makes this return False, so a spender is never ignored
on uncertain data.
"""
adb = wallet.adb
try:
height = int(adb.get_tx_height(spender).height())
except Exception:
return False
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return False
try:
label = wallet.get_label_for_txid(spender)
except Exception:
label = None
if not label or not Util._label_matches_history(label, history_label):
return False
try:
stored = adb.db.get_transaction(spender)
except Exception:
return False
if stored is None:
return False
try:
return int(stored.locktime) > int(will_locktime)
except Exception:
return False
@staticmethod
def _label_matches_history(label, history_label):
"""True when ``label`` is the ``history_label`` template with the
"{willexecutor}" token substituted by some (possibly empty) executor URL.
"""
token = "{willexecutor}"
if token in history_label:
prefix, suffix = history_label.split(token, 1)
return (
label.startswith(prefix)
and label.endswith(suffix)
and len(label) >= len(prefix) + len(suffix)
)
return label == history_label
@staticmethod @staticmethod
def cmp_output(outputa, outputb): def cmp_output(outputa, outputb):
"""Two outputs are equal when both address and value match.""" """Two outputs are equal when both address and value match."""

View File

@@ -40,9 +40,11 @@ from electrum.transaction import (
tx_from_any, tx_from_any,
) )
from electrum.util import ( from electrum.util import (
UnrelatedTransactionException,
bfh, bfh,
) )
from .heirs import WillExecutorFeeTooHighException
from .util import Util from .util import Util
from .willexecutors import Willexecutors from .willexecutors import Willexecutors
@@ -219,13 +221,8 @@ class Will:
if ow.we["url"] == nw.we["url"]: if ow.we["url"] == nw.we["url"]:
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]): if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
return anticipate return anticipate
else: elif int(ow.tx_fees) != int(nw.tx_fees):
if int(ow.tx_fees) != int(nw.tx_fees):
return anticipate return anticipate
else:
ow.tx.locktime
else:
ow.tx.locktime
else: else:
if nw.we == ow.we: if nw.we == ow.we:
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]): if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
@@ -446,18 +443,24 @@ class Will:
for wid in will: for wid in will:
wtx = will[wid].tx wtx = will[wid].tx
found = False found = False
inp = None
for inp in wtx.inputs(): for inp in wtx.inputs():
if inp.prevout.txid.hex() in will: if inp.prevout.txid.hex() in will:
found = True found = True
break break
if not found: if not found and inp is not None:
out[inp.prevout.to_str()] = inp out[inp.prevout.to_str()] = inp
return out return out
@staticmethod @staticmethod
def invalidate_will(will, wallet, fees_per_byte): def invalidate_will(will, wallet, fees_per_byte, history_label=None,
will_locktime=None):
_logger.debug("invalidate tx in will module")
will_only_valid = Will.only_valid_list(will) will_only_valid = Will.only_valid_list(will)
inputs = Will.get_all_inputs(will_only_valid) inputs = Will.get_all_inputs(will_only_valid)
if history_label is not None and will_locktime is not None:
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
else:
utxos = wallet.get_utxos() utxos = wallet.get_utxos()
filtered_inputs = [] filtered_inputs = []
prevout_to_spend = [] prevout_to_spend = []
@@ -472,11 +475,13 @@ class Will:
utxo_to_spend = [] utxo_to_spend = []
for utxo in utxos: for utxo in utxos:
if utxo.is_coinbase_output() and utxo.block_height < current_height+100: if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
_logger.debug("is not mature coinbase output")
continue continue
utxo_str = utxo.prevout.to_str() utxo_str = utxo.prevout.to_str()
if utxo_str in prevout_to_spend: if utxo_str in prevout_to_spend:
balance += inputs[utxo_str][0][2].value_sats() balance += inputs[utxo_str][0][2].value_sats()
utxo_to_spend.append(utxo) utxo_to_spend.append(utxo)
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
if len(utxo_to_spend) > 0: if len(utxo_to_spend) > 0:
change_addresses = wallet.get_change_addresses_for_new_transaction() change_addresses = wallet.get_change_addresses_for_new_transaction()
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance) out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
@@ -508,7 +513,7 @@ class Will:
@staticmethod @staticmethod
def is_new(will): def is_new(will):
for wid, w in will.items(): for _wid, w in will.items():
if w.get_status("VALID") and not w.get_status("COMPLETE"): if w.get_status("VALID") and not w.get_status("COMPLETE"):
return True return True
@@ -534,10 +539,26 @@ class Will:
wi.set_status("INVALIDATED", True) wi.set_status("INVALIDATED", True)
else: else:
if wallet.db.get_transaction(wi._id): # The funding outpoint is not part of the will tree:
wi.set_status("CONFIRMED", True) # decide from whether a broadcast transaction really
else: # spends it (a wallet-local history copy of the same
# will tx must neither turn the item CONFIRMED nor
# INVALIDATED - it is just a persistence artifact).
stored = None
if wallet and getattr(wallet, "db", None):
try:
stored = wallet.db.get_transaction(wi._id)
except Exception:
stored = None
spender_height = Will._funding_spender_height(wallet, inp)
if spender_height is None:
if stored:
continue
wi.set_status("INVALIDATED", True) wi.set_status("INVALIDATED", True)
elif spender_height == 0:
wi.set_status("MEMPOOL", True)
else:
wi.set_status("CONFIRMED", True)
for child in wi.search(all_inputs): for child in wi.search(all_inputs):
if child.tx.locktime < wi.tx.locktime: if child.tx.locktime < wi.tx.locktime:
@@ -575,9 +596,17 @@ class Will:
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)
if inp_str not in utxos_list: if inp_str not in utxos_list:
if wallet: if not wallet or not getattr(wallet, "adb", None):
continue
height = Will.check_tx_height(w.tx, wallet) height = Will.check_tx_height(w.tx, wallet)
if height < 0: if height < 0:
# The will tx itself is not on-chain. A missing
# funding UTXO is only a real problem when a
# broadcast transaction actually spends it; a
# wallet-local (history) copy of the same will tx
# marks the funding spent locally and must not
# invalidate the will.
if Will._funding_really_spent(wallet, inp_str):
Will.set_invalidate(wid, willtree) Will.set_invalidate(wid, willtree)
elif height == 0: elif height == 0:
w.set_status("MEMPOOL", True) w.set_status("MEMPOOL", True)
@@ -598,7 +627,8 @@ class Will:
# Will.reflect_to_children(wc) # Will.reflect_to_children(wc)
@staticmethod @staticmethod
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust): def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust,
max_fee=None):
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = ( fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True) heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
) )
@@ -614,13 +644,92 @@ class Will:
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%") raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
for url, wex in willexecutors.items(): for url, wex in willexecutors.items():
if Willexecutors.is_selected(wex): if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
if max_fee is not None and int(wex["base_fee"]) > max_fee:
raise WillExecutorFeeTooHighException(wex, max_fee)
temp_balance = wallet_balance - int(wex["base_fee"]) temp_balance = wallet_balance - int(wex["base_fee"])
if fixed_amount >= temp_balance: if fixed_amount >= temp_balance:
raise FixedAmountException( raise FixedAmountException(
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}" f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
) )
@staticmethod
def _funding_really_spent(wallet, inp_str):
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
will tx from the local history) and future transactions, so this is True
only when the funding was consumed by a real on-chain/mempool tx.
"""
if not wallet or not getattr(wallet, "adb", None):
return False
try:
return wallet.adb.get_spender(inp_str) is not None
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return False
@staticmethod
def _funding_spender_height(wallet, inp_str):
"""Mined height of the broadcast tx spending ``inp_str``, or None.
Returns ``None`` when no broadcast transaction spends the outpoint (a
wallet-local history spender or a future tx are ignored by
``adb.get_spender``). The height is 0 for a mempool spender and positive
for a confirmed one.
"""
if not wallet or not getattr(wallet, "adb", None):
return None
try:
spender = wallet.adb.get_spender(inp_str)
except Exception as e:
_logger.error(f"get_spender failed for {inp_str}: {e}")
return None
if spender is None:
return None
try:
return int(wallet.adb.get_tx_height(spender).height())
except Exception as e:
_logger.error(f"get_tx_height failed for {spender}: {e}")
return 0
@staticmethod
def _absorb_history_signatures(will, wallet):
"""Merge signatures from the wallet's stored local copy of each will tx.
An incomplete will transaction saved into the local history (see
``save_valid_transactions_to_history``) may later accumulate signatures
(e.g. after a manual merge from a more complete copy). The in-memory
will item would otherwise miss those signatures on the next check. For
every item whose stored wallet copy is the same partial transaction the
signatures are merged into the in-memory one and, if it becomes fully
signed, the item is marked COMPLETE.
This method must never raise: history absorption is a convenience on top
of the will check, so any failure is logged and ignored.
"""
if not wallet or not getattr(wallet, "db", None):
return
for wi in will.values():
try:
if (
wi.tx is None
or not isinstance(wi.tx, PartialTransaction)
or wi.tx.is_complete()
):
continue
stored = wallet.db.get_transaction(wi._id)
if (
not isinstance(stored, Transaction)
or stored.txid() != wi.tx.txid()
):
continue
wi.tx.combine_with_other_psbt(stored)
if wi.tx.is_complete():
wi.set_status("COMPLETE", True)
except Exception as e:
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
@staticmethod @staticmethod
def check_will(will, all_utxos, wallet, timestamp_to_check): def check_will(will, all_utxos, wallet, timestamp_to_check):
"""Validate a will against the current wallet state. """Validate a will against the current wallet state.
@@ -636,6 +745,7 @@ class Will:
timestamp_to_check: The reference UNIX timestamp (usually "now") timestamp_to_check: The reference UNIX timestamp (usually "now")
used to decide whether any transaction has expired. used to decide whether any transaction has expired.
""" """
Will._absorb_history_signatures(will, wallet)
Will.add_willtree(will) Will.add_willtree(will)
utxos_list = Will.utxos_strs(all_utxos) utxos_list = Will.utxos_strs(all_utxos)
@@ -649,6 +759,186 @@ class Will:
Will.search_rai(all_inputs, all_utxos, will, wallet) Will.search_rai(all_inputs, all_utxos, will, wallet)
Will.check_signatures(will, wallet)
@staticmethod
def save_valid_transactions_to_history(will, wallet, history_label):
"""Keep the wallet's LOCAL history in sync with the current will state.
Called after the will has been built/signed/checked (see the
SAVE_HISTORY / HISTORY_LABEL settings). A will transaction belongs in the
local history while it is still "New" (not yet fully signed - i.e. an
incomplete partial transaction), and must be removed once it becomes
"Complete" (fully signed), because at that point it is ready to be
broadcast and will appear in the history on its own.
For every will item that is valid and whose transaction has a txid it:
1. decodes the label template, replacing "{willexecutor}" with the
will-executor URL of the item,
2. if the transaction is NOT complete, stores it via
``wallet.adb.add_transaction`` (merging signatures when an
already-stored partial transaction is upgraded by a more complete
one) and tags it with the decoded label,
3. if the transaction IS complete, does not store it: its matching
local-history entry is removed by the cleanup below.
Finally it deletes every wallet-local transaction whose label exactly
matches the decoded label of a current valid item but that is no longer
among the just-saved transactions, so fully-signed, rebuilt or replaced
wills do not pile up stale entries.
This method must never raise: history persistence is a convenience on
top of the will check, so any failure is logged and ignored.
Args:
will: The will dictionary (WillItem entries keyed by txid).
wallet: The Electrum wallet object (may be falsy for offline
checks, in which case this is a no-op).
history_label: The label template to apply (may contain
"{willexecutor}").
"""
if not wallet or not getattr(wallet, "adb", None):
return
saved_txids = []
try:
current_labels = {
history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
for wi in will.values()
if wi.get_status("VALID")
and wi.tx is not None
and wi.tx.txid() is not None
}
for wi in will.values():
if not wi.get_status("VALID"):
continue
if wi.tx is None or wi.tx.txid() is None:
continue
# Fully-signed (complete) transactions must NOT be saved: they
# are removed from the local history so the list does not show a
# placeholder for a transaction that will appear on its own once
# broadcast/confirmed. Only the not-yet-complete "New" items are
# stored. Note that fully-segwit partial txs have a txid even
# when incomplete, so the txid() check alone is not enough.
if wi.tx.is_complete():
continue
try:
txid = wi.tx.txid()
label = history_label.replace(
"{willexecutor}", (wi.we or {}).get("url", "")
)
Will._add_transaction_to_history(wallet, wi.tx, txid)
try:
wallet.set_label(txid, label)
except Exception as e:
_logger.error(f"set_label failed for {txid}: {e}")
saved_txids.append(txid)
except Exception as e:
_logger.error(f"save to history failed for item {wi._id}: {e}")
# Delete stale wallet-local txs whose label matches a current valid
# item but that are no longer among the saved ones. This removes
# entries for fully-signed (complete) items and for rebuilt/replaced
# wills with the same executor.
for txid, label in Will._wallet_labels(wallet):
if txid in saved_txids:
continue
if label not in current_labels:
continue
try:
wallet.adb.remove_transaction(txid)
try:
wallet.set_label(txid, None)
except Exception:
pass
except Exception as e:
_logger.error(f"remove from history failed for {txid}: {e}")
try:
wallet.save_db()
except Exception as e:
_logger.error(f"save_db failed after history update: {e}")
except Exception as e:
_logger.error(f"save_valid_transactions_to_history failed: {e}")
@staticmethod
def _add_transaction_to_history(wallet, tx, txid):
"""Store *tx* into the wallet's local history via ``adb``.
If a partial transaction with the same txid is already stored and *tx*
carries additional signatures, the signatures are merged into the stored
one before saving. ``allow_unrelated`` is retried as a fallback so that
self-created txs (which are not yet part of the wallet's UTXO set) are
still accepted.
"""
adb = wallet.adb
existing = None
try:
existing = wallet.db.get_transaction(txid)
except Exception:
existing = None
try:
if (
isinstance(existing, PartialTransaction)
and not existing.is_complete()
and isinstance(tx, PartialTransaction)
):
existing.combine_with_other_psbt(tx)
adb.add_transaction(existing)
else:
try:
adb.add_transaction(tx)
except UnrelatedTransactionException:
adb.add_transaction(tx, allow_unrelated=True)
except Exception as e:
raise RuntimeError(f"add_transaction failed for {txid}: {e}") from e
@staticmethod
def _wallet_labels(wallet):
"""Return the wallet's ``(txid, label)`` pairs in a defensive way."""
try:
get_all_labels = wallet.get_all_labels
except AttributeError:
return []
try:
return list(get_all_labels().items())
except Exception as e:
_logger.error(f"get_all_labels failed: {e}")
return []
@staticmethod
def check_signatures(will, wallet=None):
"""Refresh the per-item signature counts and the PARTIALLY_SIGNED status.
The signature counts are derived from the transaction itself via
Electrum's ``signature_count()``, which needs a script descriptor on
each input (attached from the wallet when available). Items that already
carry their own descriptors (e.g. imported/merged partial transactions)
are counted even without a wallet.
An item with at least one signature present but fewer than required is
marked PARTIALLY_SIGNED. Items that are already signed (COMPLETE) or
whose transaction is complete always clear the flag.
"""
for wi in will.values():
try:
if wi.get_status("COMPLETE") or wi.tx is None or wi.tx.is_complete():
wi.set_status("PARTIALLY_SIGNED", False)
continue
if wallet:
wi.tx.add_info_from_wallet(wallet)
if not hasattr(wi.tx, "signature_count"):
continue
have, required = wi.tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
if required > 1 and 0 < have < required:
wi.set_status("PARTIALLY_SIGNED", True)
else:
wi.set_status("PARTIALLY_SIGNED", False)
except Exception as e:
_logger.error(f"check_signatures failed for item {wi._id}: {e}")
@staticmethod @staticmethod
def get_min_locktime(will,default_value=None): def get_min_locktime(will,default_value=None):
return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value) return min((v.tx.locktime for v in will.values() if v.get_status('VALID')), default=default_value)
@@ -777,7 +1067,7 @@ class Will:
timestamp_to_check: Reference UNIX timestamp (usually "now"). 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 _inputs, 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)
@@ -854,8 +1144,6 @@ class Will:
if heir := heirs.get(wheir, None): if heir := heirs.get(wheir, None):
if heir[0] == their[0] and heir[1] == their[1]: if heir[0] == their[0] and heir[1] == their[1]:
# The requested (possibly new) locktime for this heir.
new_locktime = Util.parse_locktime_string(heir[2])
# IMPORTANT: compare against the locktime that is # IMPORTANT: compare against the locktime that is
# actually frozen inside the already-signed Bitcoin # actually frozen inside the already-signed Bitcoin
# transaction (w.tx.locktime), NOT against their[2]. # transaction (w.tx.locktime), NOT against their[2].
@@ -866,6 +1154,16 @@ class Will:
# undetected. w.tx.locktime is immutable once signed # undetected. w.tx.locktime is immutable once signed
# and is exactly what the will-executors hold. # and is exactly what the will-executors hold.
tx_locktime = int(w.tx.locktime) tx_locktime = int(w.tx.locktime)
# The requested (possibly new) locktime for this heir.
# A RELATIVE recipe ("1y"/"30d") is resolved against
# the moment the signed tx was built, NOT against now:
# re-parsing it from "now" drifts it one day per day
# away from the frozen tx locktime, so an UNCHANGED
# will would be read as a POSTPONE and the plugin
# would ask to invalidate it every day.
new_locktime = Util.resolve_locktime_against_tx(
heir[2], their[2], tx_locktime
)
if new_locktime == tx_locktime: if new_locktime == tx_locktime:
# Unchanged: this heir is still coherent. # Unchanged: this heir is still coherent.
count = heirs_found.get(wheir, 0) count = heirs_found.get(wheir, 0)
@@ -937,7 +1235,7 @@ class Will:
if self_willexecutor and no_willexecutor == 0: if self_willexecutor and no_willexecutor == 0:
raise NoWillExecutorNotPresent("Backup tx") raise NoWillExecutorNotPresent("Backup tx")
for url, we in willexecutors.items(): for url, we in willexecutors.items():
if Willexecutors.is_selected(we): if Willexecutors.is_selected(we) and Willexecutors.is_valid(we):
if url not in willexecutors_found: if url not in willexecutors_found:
_logger.debug(f"will-executor: {url} not fount") _logger.debug(f"will-executor: {url} not fount")
raise WillExecutorNotPresent(url) raise WillExecutorNotPresent(url)
@@ -974,6 +1272,7 @@ class WillItem(Logger):
"MEMPOOL": ["Mempool", False], "MEMPOOL": ["Mempool", False],
"PUSH_FAIL": ["Push failed", False], "PUSH_FAIL": ["Push failed", False],
"PUSHED": ["Pushed", False], "PUSHED": ["Pushed", False],
"PARTIALLY_SIGNED": ["Partially Signed", False],
"REPLACED": ["Replaced", False], "REPLACED": ["Replaced", False],
"RESTORED": ["Restored", False], "RESTORED": ["Restored", False],
"UPDATED": ["Updated", False], "UPDATED": ["Updated", False],
@@ -1032,6 +1331,9 @@ class WillItem(Logger):
self.STATUS["PUSHED"][1] = True self.STATUS["PUSHED"][1] = True
self.STATUS["PUSH_FAIL"][1] = False self.STATUS["PUSH_FAIL"][1] = False
if status in ["COMPLETE"]:
self.STATUS["PARTIALLY_SIGNED"][1] = False
return value return value
def get_status(self, status): def get_status(self, status):
@@ -1052,6 +1354,8 @@ class WillItem(Logger):
self.time = w.get("time", None) self.time = w.get("time", None)
self.change = w.get("change", None) self.change = w.get("change", None)
self.tx_fees = w.get("baltx_fees", 0) self.tx_fees = w.get("baltx_fees", 0)
self.sigs_required = int(w.get("sigs_required", 0))
self.sigs_have = int(w.get("sigs_have", 0))
self.father = w.get("Father", None) self.father = w.get("Father", None)
self.children = w.get("Children", None) self.children = w.get("Children", None)
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT) self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
@@ -1088,6 +1392,8 @@ class WillItem(Logger):
"time": self.time, "time": self.time,
"change": self.change, "change": self.change,
"baltx_fees": self.tx_fees, "baltx_fees": self.tx_fees,
"sigs_required": self.sigs_required,
"sigs_have": self.sigs_have,
} }
for key in self.STATUS: for key in self.STATUS:
try: try:

View File

@@ -17,13 +17,16 @@ interaction is handled by the Qt layer.
import json import json
import time import time
from datetime import datetime from datetime import datetime
from typing import Any
from aiohttp import ClientResponse from aiohttp import ClientResponse
from electrum import bitcoin, constants
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import Network from electrum.network import Network
from .plugin_base import BalPlugin from .plugin_base import BalPlugin, get_version
# Per-request timeout (seconds) for interactive operations (ping / info / # Per-request timeout (seconds) for interactive operations (ping / info /
# list download). These fail fast (no retries) so a dead server does not # list download). These fail fast (no retries) so a dead server does not
@@ -152,7 +155,7 @@ class Willexecutors:
@staticmethod @staticmethod
def get_willexecutors( def get_willexecutors(
bal_plugin, update=False, bal_window=False, force=False, task=True bal_plugin, update=False, bal_window: Any = None, force=False, task=True
): ):
willexecutors = bal_plugin.WILLEXECUTORS.get() willexecutors = bal_plugin.WILLEXECUTORS.get()
willexecutors = willexecutors.get(chainname, {}) willexecutors = willexecutors.get(chainname, {})
@@ -214,6 +217,20 @@ class Willexecutors:
willexecutor["selected"] = False willexecutor["selected"] = False
return False return False
@staticmethod
def is_valid(willexecutor, max_fee=None, dust=None):
if not willexecutor:
return False
address = willexecutor.get("address", "")
if not address or not bitcoin.is_address(address, net=constants.net):
return False
base_fee = int(willexecutor.get("base_fee", 0))
if dust is not None and base_fee < dust:
return False
if max_fee is not None and base_fee > max_fee:
return False
return True
@staticmethod @staticmethod
def get_willexecutor_transactions(will, force=False): def get_willexecutor_transactions(will, force=False):
willexecutors = {} willexecutors = {}
@@ -272,10 +289,12 @@ class Willexecutors:
raise Exception("You are offline.") raise Exception("You are offline.")
_logger.debug(f"<-- {method} {url} {data}") _logger.debug(f"<-- {method} {url} {data}")
headers = {} headers = {}
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}" headers["user-agent"] = f"BalPlugin v:{get_version()}"
headers["Content-Type"] = "text/plain" headers["Content-Type"] = "text/plain"
if not handle_response: if not handle_response:
handle_response = Willexecutors.handle_response handle_response = Willexecutors.handle_response
attempts = max_retries + 1
for attempt in range(attempts):
try: try:
if method == "get": if method == "get":
response = Network.send_http_on_proxy( response = Network.send_http_on_proxy(
@@ -297,30 +316,21 @@ class Willexecutors:
) )
else: else:
raise Exception(f"unexpected {method=!r}") raise Exception(f"unexpected {method=!r}")
_logger.debug(f"--> {response}")
return response
except TimeoutError: except TimeoutError:
if count_reply < max_retries: if attempt < max_retries:
_logger.debug( _logger.debug(
f"timeout({count_reply}) error: retry in {retry_sleep} sec..." f"timeout({attempt}) error: "
f"retry in {retry_sleep} sec..."
) )
if retry_sleep: if retry_sleep:
time.sleep(retry_sleep) time.sleep(retry_sleep)
return Willexecutors.send_request(
method,
url,
data,
timeout=timeout,
handle_response=handle_response,
count_reply=count_reply + 1,
max_retries=max_retries,
retry_sleep=retry_sleep,
)
else: else:
_logger.debug(f"Too many timeouts: {count_reply}") _logger.debug(f"Too many timeouts: {attempt}")
except Exception as e: except Exception as e:
raise e raise e
else: return None
_logger.debug(f"--> {response}")
return response
@staticmethod @staticmethod
def get_we_url_from_response(resp): def get_we_url_from_response(resp):
@@ -331,15 +341,11 @@ class Willexecutors:
@staticmethod @staticmethod
async def handle_response(resp: ClientResponse): async def handle_response(resp: ClientResponse):
resp.raise_for_status()
r = await resp.text() r = await resp.text()
try: try:
r = json.loads(r) r = json.loads(r)
# url = Willexecutors.get_we_url_from_response(resp) except json.JSONDecodeError:
# r["url"]= url
# r["status"]=resp.status
except Exception as e:
_logger.debug(f"error handling response:{e}")
pass pass
return r return r
@@ -368,17 +374,19 @@ class Willexecutors:
max_retries=max_retries, max_retries=max_retries,
retry_sleep=retry_sleep, retry_sleep=retry_sleep,
): ):
willexecutor["broadcast_status"] = _("Success")
_logger.debug(f"pushed: {w}") _logger.debug(f"pushed: {w}")
if w != "thx": if w != "thx":
_logger.debug(f"error: {w}") _logger.debug(f"error: {w}")
raise Exception(w) raise Exception(w)
willexecutor["broadcast_status"] = _("Success")
else: else:
raise Exception("empty reply from:{willexecutor['url']}") raise Exception(
f"empty reply from:{willexecutor['url']}"
)
except Exception as e: except Exception as e:
_logger.debug(f"error:{e}") _logger.debug(f"error:{e}")
if str(e) == "already present": if str(e) == "already present":
raise Willexecutors.AlreadyPresentException() raise Willexecutors.AlreadyPresentException() from None
out = False out = False
willexecutor["broadcast_status"] = _("Failed") willexecutor["broadcast_status"] = _("Failed")
@@ -404,11 +412,34 @@ class Willexecutors:
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
) )
if isinstance(w, dict): if isinstance(w, dict):
address = w.get("address")
if not isinstance(address, str) or not bitcoin.is_address(
address, net=constants.net
):
_logger.warning(
f"invalid address from {url}: {address!r}"
)
willexecutor["status"] = "KO"
else:
base_fee = w.get("base_fee")
try:
base_fee = int(base_fee or 0)
if base_fee < 0:
raise ValueError("negative fee")
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
raise ValueError("fee exceeds total coin supply")
except (TypeError, ValueError) as e:
_logger.warning(
f"invalid base_fee from {url}: "
f"{w.get('base_fee')!r} ({e})"
)
willexecutor["status"] = "KO"
else:
willexecutor["url"] = url willexecutor["url"] = url
willexecutor["status"] = 200 willexecutor["status"] = 200
willexecutor["base_fee"] = w["base_fee"] willexecutor["base_fee"] = base_fee
willexecutor["address"] = w["address"] willexecutor["address"] = address
willexecutor["info"] = w["info"] willexecutor["info"] = w.get("info", "")
else: else:
# No dict reply (timeout / empty) -> mark as unreachable. # No dict reply (timeout / empty) -> mark as unreachable.
willexecutor["status"] = "KO" willexecutor["status"] = "KO"
@@ -451,8 +482,7 @@ class Willexecutors:
Returns: Returns:
The same ``willexecutors`` mapping, updated in place. The same ``willexecutors`` mapping, updated in place.
""" """
from concurrent.futures import ThreadPoolExecutor, wait from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
items = list(willexecutors.items()) items = list(willexecutors.items())
if not items: if not items:
@@ -532,8 +562,7 @@ class Willexecutors:
Returns ``{url: (ok, exception_or_None)}`` for the servers that Returns ``{url: (ok, exception_or_None)}`` for the servers that
answered in time (timed-out servers are reported via ``on_timeout``). answered in time (timed-out servers are reported via ``on_timeout``).
""" """
from concurrent.futures import ThreadPoolExecutor, wait from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we] targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
results = {} results = {}
@@ -650,8 +679,7 @@ class Willexecutors:
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
that answered in time. that answered in time.
""" """
from concurrent.futures import ThreadPoolExecutor, wait from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED
targets = [(wid, url) for wid, url in items if url] targets = [(wid, url) for wid, url in items if url]
results = {} results = {}
@@ -742,7 +770,14 @@ class Willexecutors:
else: else:
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko")) willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False) willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address","")) address = old_willexecutor.get("address", willexecutor.get("address", ""))
if address and not bitcoin.is_address(address, net=constants.net):
_logger.warning(
f"invalid address {address!r} for executor {url}, "
f"falling back to empty"
)
address = ""
willexecutor["address"] = address
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code")) willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
@@ -755,14 +790,23 @@ class Willexecutors:
"get", "get",
f"{welist_server}data/{chainname}?page=0&limit=100", f"{welist_server}data/{chainname}?page=0&limit=100",
) )
# del willexecutors["status"] if not isinstance(willexecutors, dict):
_logger.warning(
f"unexpected download_list response type: "
f"{type(willexecutors).__name__}"
)
return {}
for w in willexecutors: for w in willexecutors:
if w not in ("status", "url"): if w not in ("status", "url"):
if not isinstance(willexecutors.get(w), dict):
_logger.warning(
f"malformed entry {w!r} in executor list, "
f"type={type(willexecutors.get(w)).__name__}"
)
continue
Willexecutors.initialize_willexecutor( Willexecutors.initialize_willexecutor(
willexecutors[w], w, None, old_willexecutors.get(w,None) willexecutors[w], w, None, old_willexecutors.get(w,None)
) )
# bal_plugin.WILLEXECUTORS.set(l)
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
return willexecutors return willexecutors
except Exception as e: except Exception as e:
@@ -794,6 +838,12 @@ class Willexecutors:
"post", url + "/searchtx", data=txid.encode("ascii"), "post", url + "/searchtx", data=txid.encode("ascii"),
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep, timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
) )
if not isinstance(w, dict):
_logger.warning(
f"unexpected check_transaction response type "
f"from {url}: {type(w).__name__}"
)
return None
return w return w
except Exception as e: except Exception as e:
_logger.error(f"error contacting {url} for checking txs {e}") _logger.error(f"error contacting {url} for checking txs {e}")

View File

@@ -7,13 +7,19 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application. into an RFC-5545 .ics file and opens it with the OS default application.
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
the Qt button and the OS/subprocess glue.
""" """
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QToolButton from PyQt6.QtWidgets import QToolButton
from ...core.reminders import write_temp_ics
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
class BalCalendarButton(QToolButton): class BalCalendarButton(QToolButton):
"""A QToolButton with a dropdown menu for .ics calendar file actions. """A QToolButton with a dropdown menu for .ics calendar file actions.
@@ -56,7 +62,7 @@ class BalCalendarButton(QToolButton):
try: try:
content = self._ics_provider() content = self._ics_provider()
if content: if content:
self._calendar_temp_path = BalCalendar.write_temp_ics(content) self._calendar_temp_path = write_temp_ics(content)
else: else:
self._calendar_temp_path = None self._calendar_temp_path = None
self._bal_window.show_warning( self._bal_window.show_warning(
@@ -79,7 +85,8 @@ class BalCalendarButton(QToolButton):
path = self._ensure_ics() path = self._ensure_ics()
if not path: if not path:
return return
import shlex, subprocess import shlex
import subprocess
if self._bal_window.bal_plugin.is_basic_mode(): if self._bal_window.bal_plugin.is_basic_mode():
app = self._bal_window.bal_plugin.CALENDAR_APP.default app = self._bal_window.bal_plugin.CALENDAR_APP.default
else: else:
@@ -147,13 +154,6 @@ class BalCalendarButton(QToolButton):
class BalCalendar: class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod @staticmethod
def open_with_default_app(calendar_app, path): def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app") _logger.debug("opening calendar app")
@@ -182,52 +182,3 @@ class BalCalendar:
if os.path.isdir(desktop): if os.path.isdir(desktop):
return desktop return desktop
return home return home
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def ical_escape(text: str) -> str:
# escape per RFC5545: backslash, ; , newlines
text = text.encode("utf-8")
text = (
text.replace(b"\\", b"\\\\")
.replace(b";", b"\\;")
.replace(b",", b"\\,")
)
out =""
temp=text.split(b"\r\n")
for s in temp:
encoded= s
cut =0
while len(encoded) >75:
cut+=5
encoded=f"{s[:len(s)-cut]}"
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
cut += 1
encoded=f"{s[:len(s)-cut]}"
encoded=f"{encoded}...\r\n".encode("utf-8")
if cut>0:
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
else:
out+=str(f"{s.decode()}\r\n")
return out[:-2]
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# taglia senza spezzare byte UTF-8
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)

View File

@@ -12,7 +12,7 @@ hosts a few GUI helpers that do not deserve a module of their own:
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid. * :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
* :func:`log_error` - format an exception traceback for a dialog. * :func:`log_error` - format an exception traceback for a dialog.
* :func:`export_meta_gui` - export plugin metadata to a JSON file. * :func:`export_meta_gui` - export plugin metadata to a JSON file.
* :class:`CheckAliveError`- raised when the "check alive" date is in the past. (:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
""" """
import copy import copy
@@ -27,62 +27,138 @@ from decimal import Decimal
from functools import partial from functools import partial
from typing import Any, Callable, Mapping, Optional, Union from typing import Any, Callable, Mapping, Optional, Union
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
NLOCKTIME_MIN)
from electrum.gui.qt.amountedit import BTCAmountEdit from electrum.gui.qt.amountedit import BTCAmountEdit
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
from electrum.gui.qt.my_treeview import MyTreeView from electrum.gui.qt.my_treeview import MyTreeView
from electrum.gui.qt.password_dialog import PasswordDialog from electrum.gui.qt.password_dialog import PasswordDialog
from electrum.gui.qt.transaction_dialog import TxDialog from electrum.gui.qt.transaction_dialog import TxDialog
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme, from electrum.gui.qt.util import (
EnterButton, HelpButton, MessageBoxMixin, Buttons,
OkButton, TaskThread, WindowModalDialog, CancelButton,
char_width_in_lineedit, getSaveFileName, ColorScheme,
import_meta_gui, read_QIcon_from_bytes, EnterButton,
read_QPixmap_from_bytes, webopen) HelpButton,
MessageBoxMixin,
OkButton,
TaskThread,
WindowModalDialog,
char_width_in_lineedit,
getOpenFileName,
getSaveFileName,
import_meta_gui,
read_QIcon_from_bytes,
read_QPixmap_from_bytes,
webopen,
)
from electrum.i18n import _ from electrum.i18n import _
from electrum.logging import get_logger from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
from electrum.payment_identifier import PaymentIdentifier from electrum.payment_identifier import PaymentIdentifier
from electrum.plugin import hook from electrum.plugin import hook
from electrum.transaction import SerializationError, Transaction, tx_from_any from electrum.transaction import SerializationError, Transaction, tx_from_any
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled, from electrum.util import (
decimal_point_to_base_unit_name, read_json_file, DECIMAL_POINT,
write_json_file) FileExportFailed,
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize, FileImportFailed,
Qt, QTimer, pyqtSignal) UserCancelled,
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem, decimal_point_to_base_unit_name,
QStandardItemModel) read_json_file,
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox, write_json_file,
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout, )
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu, from PyQt6.QtCore import (
QMenuBar, QPushButton, QScrollArea, QSizePolicy, QDateTime,
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame, QModelIndex,
QVBoxLayout, QWidget, QDialog) QPersistentModelIndex,
QSize,
Qt,
QTimer,
pyqtSignal,
)
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QCheckBox,
QComboBox,
QDateTimeEdit,
QDialog,
QGridLayout,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMenuBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QStyle,
QStyleOptionFrame,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from ...core.heirs import (
HEIR_DUST_AMOUNT,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
HeirAmountIsDustException,
Heirs,
WillExecutorFeeTooHighException,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
)
# --- Core (GUI-free) logic layer --- # --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import (HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT,
HeirAmountIsDustException, Heirs)
from ...core.util import Util from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException, from ...core.will import (
HeirNotFoundException, NoHeirsException, AmountException,
NotCompleteWillException, NoWillExecutorNotPresent, HeirChangeException,
TxFeesChangedException, Will, HeirNotFoundException,
WillexecutorChangeException, WillExecutorNotPresent, NoHeirsException,
WillExpiredException, WillItem, WillPostponedException) NotCompleteWillException,
from ...core.willexecutors import Willexecutors NoWillExecutorNotPresent,
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401 TxFeesChangedException,
Will,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillItem,
WillPostponedException,
)
from ...core.willexecutors import ( # noqa: F401
Willexecutors,
is_onion_url,
is_tor_active,
)
# --- Presentation helpers --- # --- Presentation helpers ---
from .theme import server_status_text, server_status_tooltip, status_color from .theme import (
from .window_utils import (bring_to_front, show_modal, show_on_top, server_status_text,
stop_thread, top_level_of) server_status_tooltip,
signature_suffix,
status_color,
)
from .window_utils import (
bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
)
_logger = get_logger(__name__) _logger = get_logger(__name__)
class shown_cv: class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
_type = bool _type = bool
def __init__(self, value): def __init__(self, value):
@@ -105,18 +181,6 @@ def add_widget(grid, label, widget, row, help_):
class CheckAliveError(Exception):
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def log_error(exec_info, window=None): def log_error(exec_info, window=None):
"""Log an error and optionally show it. """Log an error and optionally show it.

View File

@@ -17,13 +17,21 @@ the few list classes they reference are imported lazily inside the methods that
use them (see ``lists`` imports below). use them (see ``lists`` imports below).
""" """
from typing import TYPE_CHECKING
from ...core.checkalive import CheckAliveError
from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget, from .widgets import (
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget, WillSettingsWidget,
WillSettingsWidget, WillWidget, basic_reminder_offsets, WillWidget,
compute_reminder_offsets) )
from .calendar import BalCalendar, BalCalendarButton
if TYPE_CHECKING:
from .window import BalWindow
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are # NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
# imported lazily where needed to avoid a dialogs<->lists import cycle. # imported lazily where needed to avoid a dialogs<->lists import cycle.
@@ -31,9 +39,7 @@ from .calendar import BalCalendar, BalCalendarButton
class BalDialog(QDialog,MessageBoxMixin): class BalDialog(QDialog,MessageBoxMixin):
_stopping = False _stopping = False
def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"): def __init__(self, parent, bal_plugin, title=None, icon="icons/bal16x16.png"):
import signal
from PyQt6.QtCore import QMetaObject, Qt from PyQt6.QtCore import QMetaObject, Qt
from PyQt6.QtWidgets import QApplication
def handler(signum, frame): def handler(signum, frame):
QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection) QMetaObject.invokeMethod(self, "close", Qt.ConnectionType.QueuedConnection)
@@ -466,7 +472,6 @@ class BalWaitingDialog(BalDialog):
def exe(self): def exe(self):
self.thread = TaskThread(self) self.thread = TaskThread(self)
self.thread.finished.connect(self.deleteLater) # see #3956 self.thread.finished.connect(self.deleteLater) # see #3956
self.thread.finished.connect(self.finished)
self.thread.add(self.task, self.on_success, self.accept, self.on_error) self.thread.add(self.task, self.on_success, self.accept, self.on_error)
# IMPORTANT: keep the *application-modal* exec() of the original code. # IMPORTANT: keep the *application-modal* exec() of the original code.
# This dialog is driven by a TaskThread whose result (on_success, e.g. # This dialog is driven by a TaskThread whose result (on_success, e.g.
@@ -481,9 +486,6 @@ class BalWaitingDialog(BalDialog):
def hello(self): def hello(self):
pass pass
def finished(self):
pass
def on_accepted(self): def on_accepted(self):
pass pass
@@ -624,7 +626,12 @@ class BalBuildWillDialog(BalDialog):
except CheckAliveError as cae: except CheckAliveError as cae:
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
tx = Will.invalidate_will( tx = Will.invalidate_will(
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
will_locktime=Will.get_min_locktime(
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
) )
if tx: if tx:
_logger.debug( _logger.debug(
@@ -645,9 +652,17 @@ class BalBuildWillDialog(BalDialog):
Will.check_amounts( Will.check_amounts(
self.bal_window.heirs, self.bal_window.heirs,
self.bal_window.willexecutors, self.bal_window.willexecutors,
self.bal_window.window.wallet.get_utxos(), Util.get_available_utxos(
self.bal_window.window.wallet,
self.bal_window.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
),
self.bal_window.date_to_check, self.bal_window.date_to_check,
self.bal_window.window.wallet.dust_threshold(), self.bal_window.window.wallet.dust_threshold(),
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
_logger.debug("variables ok") _logger.debug("variables ok")
self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK) self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK)
@@ -659,6 +674,10 @@ class BalBuildWillDialog(BalDialog):
+ "Your settings require an adjustment of the amounts" + "Your settings require an adjustment of the amounts"
) )
) )
except WillExecutorFeeTooHighException as e:
self.msg_set_checking(
self.msg_warning(f"Will-executor fee too high: {e}")
)
self.msg_set_checking() self.msg_set_checking()
have_to_build = False have_to_build = False
@@ -694,9 +713,14 @@ class BalBuildWillDialog(BalDialog):
self.msg_set_checking(_("Postponed: invalidating old will")) self.msg_set_checking(_("Postponed: invalidating old will"))
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1) fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
return None, Will.invalidate_will( return None, Will.invalidate_will(
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte self.bal_window.willitems, self.bal_window.wallet, fee_per_byte,
history_label=self.bal_window.bal_plugin.HISTORY_LABEL.get(),
will_locktime=Will.get_min_locktime(
self.bal_window.willitems,
default_value=self.bal_window.date_to_check,
),
) )
except NoHeirsException as e: except NoHeirsException:
_logger.debug("no heirs") _logger.debug("no heirs")
self.msg_set_checking("No Heirs") self.msg_set_checking("No Heirs")
except NotCompleteWillException as e: except NotCompleteWillException as e:
@@ -800,6 +824,15 @@ class BalBuildWillDialog(BalDialog):
_("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR _("Will-Executor excluded"), None, _("Skipped"), self.COLOR_ERROR
) )
except NoWillExecutorNotPresent:
_logger.debug("no will-executor selected, build interrupted")
self.msg_set_status(
_("Will-Executor"), None,
_("Not present - select one or enable backup mode"),
self.COLOR_ERROR,
)
return "no_willexecutor", None
except WillExpiredException as e: except WillExpiredException as e:
# An expired will is an EXPECTED situation (the locktime has # An expired will is an EXPECTED situation (the locktime has
# passed). After adding/changing an heir the will is rebuilt # passed). After adding/changing an heir the will is rebuilt
@@ -979,6 +1012,14 @@ class BalBuildWillDialog(BalDialog):
desired behaviour, and that the date shown in the panel/wizard must desired behaviour, and that the date shown in the panel/wizard must
reflect this anticipated date (so the calendar .ics also uses it). reflect this anticipated date (so the calendar .ics also uses it).
RELATIVE dates are additionally normalised here: a relative value
("30d"/"1y") is re-parsed against "now" on every check, so it drifts
away from the fixed transaction locktime and the postpone check would
wrongly ask to invalidate the will every day. The stored locktime is
therefore frozen to the built transactions' absolute locktime, and a
relative threshold is frozen to its "N days before the delivery"
absolute value.
We route the update through BalWindow.update_setting_widgets, which is We route the update through BalWindow.update_setting_widgets, which is
the single place that (1) stores the value in WILL_SETTINGS, (2) the single place that (1) stores the value in WILL_SETTINGS, (2)
persists it to Electrum's database and (3) refreshes the date widgets in persists it to Electrum's database and (3) refreshes the date widgets in
@@ -991,30 +1032,72 @@ class BalBuildWillDialog(BalDialog):
if min_locktime is None: if min_locktime is None:
return return
min_locktime = int(min_locktime) min_locktime = int(min_locktime)
stored_locktime = self.bal_window.will_settings["locktime"]
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
# against "now" on every check, so it drifts one day per day away from
# the fixed tx locktime and the postpone check would ALWAYS see a
# postpone -> the plugin asks to invalidate the will every day. It must
# therefore be normalised here to the frozen absolute locktime of the
# built transactions, even when it happens to parse to the same moment
# today. (Only an absolute stored value is comparable, see below.)
is_relative_locktime = (
isinstance(stored_locktime, str)
and stored_locktime[-1:].lower() in ("d", "y")
)
# Current stored delivery date, as a comparable UNIX timestamp. # Current stored delivery date, as a comparable UNIX timestamp.
try: try:
current = int( current = int(Util.parse_locktime_string(stored_locktime))
except Exception:
# If the stored value cannot be parsed, fall back to syncing.
current = None
# A genuine user-chosen POSTPONE (a later absolute date) is never
# overwritten; anything else is synced to the built transactions.
was_anticipation = current is not None and min_locktime < current
if not is_relative_locktime and current is not None and not was_anticipation:
pass
else:
_logger.debug(
f"sync delivery date to built tx locktime: "
f"{current} -> {min_locktime}"
)
# Remember that we anticipated the date, so the later sign prompt can
# explain WHY signing is needed (see on_success_phase1). A pure
# relative->absolute normalisation is NOT an anticipation.
if was_anticipation:
self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and refreshes
# the date widgets in all panels/wizard (the .ics calendar too).
self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True
)
# Same moving-target problem for a relative "Check Alive" threshold:
# it means "N days BEFORE the delivery" (the settings widget resolves it
# as real_threshold = locktime - N days), so it is normalised to that
# absolute date, referenced against the now-absolute stored locktime.
threshold_raw = self.bal_window.will_settings.get("threshold")
if (
isinstance(threshold_raw, str)
and threshold_raw[-1:].lower() in ("d", "y")
):
try:
locktime_ts = int(
Util.parse_locktime_string( Util.parse_locktime_string(
self.bal_window.will_settings["locktime"] self.bal_window.will_settings["locktime"]
) )
) )
except Exception: real_threshold = int(
# If the stored value cannot be parsed, fall back to syncing. BalTimestamp(threshold_raw)
current = None .to_date(locktime_ts, reverse=True)
# Only anticipate (move the date EARLIER); never overwrite a postpone. .timestamp()
if current is not None and min_locktime >= current: )
return except Exception as e:
_logger.debug( _logger.error(f"sync threshold to absolute failed: {e}")
f"sync delivery date to anticipated tx locktime: " else:
f"{current} -> {min_locktime}" _logger.debug(
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
) )
# Remember that we anticipated the date, so the later sign prompt can
# explain WHY signing is needed (see on_success_phase1).
self._date_was_anticipated = True
# update_setting_widgets stores the value, persists it and refreshes the
# date widgets in all panels/wizard (so the .ics calendar uses it too).
self.bal_window.update_setting_widgets( self.bal_window.update_setting_widgets(
min_locktime, "locktime", update_all=True real_threshold, "threshold", update_all=True
) )
def on_accept(self): def on_accept(self):
@@ -1113,7 +1196,13 @@ class BalBuildWillDialog(BalDialog):
selected = { selected = {
url: we url: we
for url, we in willexecutors.items() for url, we in willexecutors.items()
if Willexecutors.is_selected(self.bal_window.willexecutors.get(url)) if Willexecutors.is_selected(
self.bal_window.willexecutors.get(url),
) and Willexecutors.is_valid(
self.bal_window.willexecutors.get(url),
max_fee=self.bal_window.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.bal_window.window.wallet.dust_threshold(),
)
} }
# Servers that report "already present" need their stored tx # Servers that report "already present" need their stored tx
@@ -1346,6 +1435,10 @@ class BalBuildWillDialog(BalDialog):
QTimer.singleShot(0, self.bal_window.invalidate_will) QTimer.singleShot(0, self.bal_window.invalidate_will)
return return
if self.have_to_sign == "no_willexecutor":
self._add_no_willexecutor_buttons()
return
_logger.debug("have to sign {}".format(self.have_to_sign)) _logger.debug("have to sign {}".format(self.have_to_sign))
password = None password = None
if self.have_to_sign is None: if self.have_to_sign is None:
@@ -1451,6 +1544,13 @@ class BalBuildWillDialog(BalDialog):
def on_success_phase2(self, arg=False): def on_success_phase2(self, arg=False):
self.thread.stop() self.thread.stop()
self.bal_window.save_willitems() self.bal_window.save_willitems()
# After the whole check/sign/broadcast cycle, keep the wallet's local
# history in sync with the current will state (save the still "New"
# incomplete txs, remove the now-complete ones).
try:
self.bal_window._save_will_to_history()
except Exception as e:
_logger.error(f"save_will_to_history after phase2 failed: {e}")
self.msg_edit_row(_("Finished")) self.msg_edit_row(_("Finished"))
# Instead of auto-closing after a countdown, let the user decide when to # Instead of auto-closing after a countdown, let the user decide when to
# dismiss the dialog: they can read the full "Building Will" report at # dismiss the dialog: they can read the full "Building Will" report at
@@ -1479,9 +1579,76 @@ class BalBuildWillDialog(BalDialog):
self.vbox.addLayout(button_row) self.vbox.addLayout(button_row)
self._close_button.setFocus() self._close_button.setFocus()
# ------------------------------------------------------------------ #
# No-willexecutor error handling
# ------------------------------------------------------------------ #
def _add_no_willexecutor_buttons(self):
"""Add "Will-Executor" and "Close" buttons when no executor is
selected and ``no_willexecutor`` is ``False``."""
if getattr(self, "_no_we_buttons_added", False):
return
self._no_we_buttons_added = True
btn_row = QHBoxLayout()
btn_row.addStretch(1)
we_btn = QPushButton(_("Will-Executor"))
we_btn.clicked.connect(self._open_willexecutor_dialog)
btn_row.addWidget(we_btn)
download_btn = QPushButton(_("\U0001f52e Wizard"))
download_btn.clicked.connect(self._open_willexecutor_download_widget)
btn_row.addWidget(download_btn)
close_btn = QPushButton(_("Close"))
close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn)
self._no_we_layout = btn_row
self.vbox.addLayout(btn_row)
self.resize(self.vbox.sizeHint())
def _open_willexecutor_dialog(self):
"""Open the will-executor management dialog, then auto-retry
the build when it closes."""
d = WillExecutorDialog(self.bal_window, parent=self)
d.exec()
self._retry_build_after_willexecutor()
def _open_willexecutor_download_widget(self):
"""Close the build-will dialog and re-open the wizard at the
will-executor download step so the user can add one."""
self.close()
wizard = BalWizardDialog(self.bal_window)
wizard.on_next_heir()
wizard.on_next_locktimeandfee()
wizard.exec()
def _retry_build_after_willexecutor(self):
"""Remove the no-willexecutor buttons, reset the message panel,
and re-run ``task_phase1`` on the same thread."""
self._no_we_buttons_added = False
if self._no_we_layout:
while self._no_we_layout.count():
item = self._no_we_layout.takeAt(0)
w = item.widget()
if w:
w.setParent(None)
w.deleteLater()
self.vbox.removeItem(self._no_we_layout)
self._no_we_layout = None
self.labels = []
self.msg_update()
self.thread.add(
self.task_phase1,
on_success=self.on_success_phase1,
on_done=self.on_accept,
on_error=self.on_error_phase1,
)
def _ics_provider(self): def _ics_provider(self):
"""Return the .ics content for the current will data.""" """Return the .ics content for the current will data."""
from datetime import datetime, timedelta from datetime import datetime
try: try:
locktime_ts = Util.parse_locktime_string( locktime_ts = Util.parse_locktime_string(
@@ -1489,22 +1656,25 @@ class BalBuildWillDialog(BalDialog):
) )
locktime = datetime.fromtimestamp(locktime_ts) locktime = datetime.fromtimestamp(locktime_ts)
if self.bal_window.bal_plugin.is_basic_mode(): basic_mode = self.bal_window.bal_plugin.is_basic_mode()
days_to_deadline = (locktime - datetime.now()).days if basic_mode:
offsets = basic_reminder_offsets(days_to_deadline) raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else: else:
threshold_ts = BalTimestamp( threshold_ts = BalTimestamp(
self.bal_window.will_settings["threshold"] self.bal_window.will_settings["threshold"]
).to_timestamp() ).to_timestamp()
threshold = datetime.fromtimestamp(threshold_ts) threshold = datetime.fromtimestamp(threshold_ts)
days = (locktime - threshold).days raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
try: try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception: except Exception:
count = 3 num_reminders = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
heirs_details = "\r\n".join( heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, " f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1512,56 +1682,21 @@ class BalBuildWillDialog(BalDialog):
for heir in self.bal_window.heirs for heir in self.bal_window.heirs
) )
if self.bal_window.bal_plugin.is_basic_mode(): # ToDo #2: when no reminder falls in the future (the delivery date is
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default # too close or already passed), build_ics_reminders returns None so
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default # the caller shows a clear warning instead of producing an empty,
else: # seemingly-broken .ics file.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() return build_ics_reminders(
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() locktime=locktime,
basic_mode=basic_mode,
event_description = BalCalendar.ical_escape( description=raw_description,
f"{raw_description}" summary=raw_summary,
.replace("$wallet_name", str(self.bal_window.wallet)) wallet_name=str(self.bal_window.wallet),
.replace("$heirs_complete", heirs_details) heirs_details=heirs_details,
version=self.bal_window.bal_plugin.version,
num_reminders=num_reminders,
threshold=threshold,
) )
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{BalPlugin.__version__}",
]
total = len(offsets)
# ToDo #2: if no reminder falls in the future (the delivery date is
# too close or already passed), there are no events to write.
# Return None so the caller shows a clear warning instead of
# producing an empty, seemingly-broken .ics file.
if total == 0:
return None
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"
except Exception as e: except Exception as e:
_logger.error(f"failed to generate .ics: {e}") _logger.error(f"failed to generate .ics: {e}")
return None return None
@@ -1589,7 +1724,12 @@ class BalBuildWillDialog(BalDialog):
try: try:
if txs := self.bal_window.sign_transactions(password): if txs := self.bal_window.sign_transactions(password):
for txid, tx in txs.items(): for txid, tx in txs.items():
self.bal_window.willitems[txid].tx = copy.deepcopy(tx) # Re-parse instead of deepcopy (the signed tx can carry
# wallet-derived input info holding a threading.RLock,
# which copy.deepcopy cannot pickle).
self.bal_window.willitems[txid].tx = Will.get_tx_from_any(
str(tx)
)
self.bal_window.save_willitems() self.bal_window.save_willitems()
self.msg_set_signing(self.msg_ok()) self.msg_set_signing(self.msg_ok())
except Exception as e: except Exception as e:
@@ -1876,9 +2016,17 @@ class BalBuildWillDialog(BalDialog):
class WillDetailDialog(BalDialog): class WillDetailDialog(BalDialog):
def __init__(self, bal_window): def __init__(self, bal_window, will=None, threshold=None):
# ``will``/``threshold`` are passed when showing an IMPORTED (read-only)
self.will = bal_window.willitems # will. In that case every action button below operates on the imported
# will, never on the live wallet state.
self._external_will = will is not None
self.will = will if self._external_will else bal_window.willitems
if threshold is not None:
self.threshold = threshold
elif self._external_will:
self.threshold = max(wi.tx.locktime for wi in self.will.values())
else:
self.threshold = bal_window.will_settings["real_threshold"] self.threshold = bal_window.will_settings["real_threshold"]
self.bal_window = bal_window self.bal_window = bal_window
@@ -1911,8 +2059,14 @@ class WillDetailDialog(BalDialog):
b.clicked.connect(self.export_will) b.clicked.connect(self.export_will)
hlayout.addWidget(b) hlayout.addWidget(b)
b = QPushButton(_("Invalidate")) b = QPushButton(_("Invalidate"))
b.clicked.connect(bal_window.invalidate_will) b.clicked.connect(self.invalidate_will)
hlayout.addWidget(b) hlayout.addWidget(b)
self.merge_button = None
if self._external_will:
b = QPushButton(_("Merge"))
b.clicked.connect(self.merge_will)
hlayout.addWidget(b)
self.merge_button = b
self.vlayout.addWidget(w) self.vlayout.addWidget(w)
self.paint_scroll_area() self.paint_scroll_area()
@@ -1933,22 +2087,48 @@ class WillDetailDialog(BalDialog):
self.scrollbox = QScrollArea() self.scrollbox = QScrollArea()
viewport = QWidget(self.scrollbox) viewport = QWidget(self.scrollbox)
self.willlayout = QVBoxLayout(viewport) self.willlayout = QVBoxLayout(viewport)
self.detailsWidget = WillWidget(parent=self) self.detailsWidget = WillWidget(parent=self, will=self.will)
self.willlayout.addWidget(self.detailsWidget) self.willlayout.addWidget(self.detailsWidget)
self.scrollbox.setWidget(viewport) self.scrollbox.setWidget(viewport)
viewport.setLayout(self.willlayout) viewport.setLayout(self.willlayout)
def ask_password_and_sign_transactions(self): def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update) self.bal_window.ask_password_and_sign_transactions(
callback=self.update, will=self.will if self._external_will else None
)
self.update() self.update()
def broadcast_transactions(self): def broadcast_transactions(self):
self.bal_window.broadcast_transactions() self.bal_window.broadcast_transactions(
will=self.will if self._external_will else None
)
self.update() self.update()
def export_will(self): def export_will(self):
self.bal_window.export_will() self.bal_window.export_will(will=self.will if self._external_will else None)
def invalidate_will(self):
self.bal_window.invalidate_will(
will=self.will if self._external_will else None
)
def merge_will(self):
"""Merge the imported will into the live will and switch to it.
The merge is performed by :meth:`BalWindow.merge_will` (the same
common method used by the tools-menu "Merge" action). Afterwards the
dialog stops showing the read-only imported will and operates on the
live wallet willitems directly, so any further Sign/Broadcast/Export/
Invalidate action targets the saved will items.
"""
self.bal_window.merge_will(self.will)
self._external_will = False
self.will = self.bal_window.willitems
self.threshold = self.bal_window.will_settings["real_threshold"]
if self.merge_button:
self.merge_button.hide()
self.update()
def toggle_replaced(self): def toggle_replaced(self):
self.bal_window.bal_plugin.hide_replaced() self.bal_window.bal_plugin.hide_replaced()
@@ -1967,6 +2147,7 @@ class WillDetailDialog(BalDialog):
self.update() self.update()
def update(self): def update(self):
if not self._external_will:
self.will = self.bal_window.willitems self.will = self.bal_window.willitems
pos = self.vlayout.indexOf(self.scrollbox) pos = self.vlayout.indexOf(self.scrollbox)
self.vlayout.removeWidget(self.scrollbox) self.vlayout.removeWidget(self.scrollbox)

View File

@@ -14,12 +14,60 @@ construction) for all business actions, so the heavy logic stays in ``window``
and ``dialogs``. and ``dialogs``.
""" """
from typing import TYPE_CHECKING
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
from PyQt6.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
from .dialogs import BalBuildWillDialog, BalDialog from .dialogs import BalBuildWillDialog, BalDialog
from .widgets import BalCheckBox, WillSettingsWidget
if TYPE_CHECKING:
from .window import BalWindow
def _can_sign(will_item):
"""True if a will transaction may still be signed (not fully signed)."""
return bool(will_item) and not will_item.get_status("COMPLETE")
def _can_broadcast(will_item):
"""True if a will transaction is ready to broadcast (fully signed)."""
return bool(will_item) and will_item.get_status("COMPLETE")
def _can_delete(will_item):
"""True if a will transaction may be deleted (invalid or unsigned).
Valid AND fully-signed transactions are never deletable: removing them
would silently drop a committed inheritance.
"""
return bool(will_item) and (
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
)
def _apply_select_all(willexecutors, select, valid=None):
"""Apply a bulk selection across a ``{url: we_dict}`` mapping.
``select`` is the target selection value. When ``valid`` is given (a
``{url: bool}`` validity map), the selection is restricted by validity:
selecting sets valid ones to True and invalid ones to False, while
deselecting only clears the invalid ones (valid ones keep their state).
Without ``valid`` every entry is simply set to ``select``. The mapping is
mutated in place and returned.
"""
for url, we in willexecutors.items():
if valid is not None:
if select:
we["selected"] = valid.get(url, False)
elif not valid.get(url, False):
we["selected"] = False
else:
we["selected"] = select
return willexecutors
class HeirListWidget(MyTreeView, MessageBoxMixin): class HeirListWidget(MyTreeView, MessageBoxMixin):
@@ -84,7 +132,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
self.bal_window.new_heir_dialog(edit_key) self.bal_window.new_heir_dialog(edit_key)
def on_edited(self, idx, edit_key, *, text): def on_edited(self, idx, edit_key, *, text):
original = prior_name = self.bal_window.heirs.get(edit_key) prior_name = self.bal_window.heirs.get(edit_key)
if not prior_name: if not prior_name:
return return
col = idx.column() col = idx.column()
@@ -104,11 +152,6 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
try: try:
self.bal_window.set_heir(prior_name) self.bal_window.set_heir(prior_name)
except Exception:
pass
try:
self.bal_window.set_heir((edit_key,) + original)
except Exception: except Exception:
self.update() self.update()
@@ -157,6 +200,16 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
heir = self.bal_window.heirs[key] heir = self.bal_window.heirs[key]
labels = [""] * len(self.Columns) labels = [""] * len(self.Columns)
labels[self.Columns.NAME] = key labels[self.Columns.NAME] = key
if is_op_return_address(heir[0]):
data_hex = heir[0][len(OP_RETURN_PREFIX):]
try:
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
if len(decoded) > 40:
decoded = decoded[:40] + "\u2026"
labels[self.Columns.ADDRESS] = decoded
except Exception:
labels[self.Columns.ADDRESS] = "OP_RETURN"
else:
labels[self.Columns.ADDRESS] = heir[0] labels[self.Columns.ADDRESS] = heir[0]
labels[self.Columns.AMOUNT] = Util.decode_amount( labels[self.Columns.AMOUNT] = Util.decode_amount(
heir[1], self.decimal_point heir[1], self.decimal_point
@@ -184,7 +237,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
set_current = QPersistentModelIndex(idx) set_current = QPersistentModelIndex(idx)
try: try:
self.will_settings_widget.on_locktime_change() self.will_settings_widget.on_locktime_change()
except Exception as e: except Exception:
pass pass
self.set_current_idx(set_current) self.set_current_idx(set_current)
# FIXME refresh loses sort order; so set "default" here: # FIXME refresh loses sort order; so set "default" here:
@@ -204,15 +257,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
menu.addAction(_("Import"), self.bal_window.import_heirs) menu.addAction(_("Import"), self.bal_window.import_heirs)
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs()) menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
newHeirButton = QPushButton(_("New Heir")) new_heir_button = QPushButton(_("New Heir"))
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog) new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
widget = QWidget(self) widget = QWidget(self)
layout = QHBoxLayout(widget) layout = QHBoxLayout(widget)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self) self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
layout.addWidget(self.will_settings_widget) layout.addWidget(self.will_settings_widget)
layout.addWidget(newHeirButton) layout.addWidget(new_heir_button)
toolbar.insertWidget(2, widget) toolbar.insertWidget(2, widget)
@@ -271,7 +324,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.setModel(QStandardItemModel(self)) self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder) self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
except Exception as e: except Exception:
pass pass
self.setSortingEnabled(True) self.setSortingEnabled(True)
@@ -297,36 +350,64 @@ class PreviewList(MyTreeView, MessageBoxMixin):
selected_keys.append(sel_key) selected_keys.append(sel_key)
if selected_keys and idx.isValid(): if selected_keys and idx.isValid():
column_title = self.model().horizontalHeaderItem(column).text() column_title = self.model().horizontalHeaderItem(column).text()
# column_data = "\n".join(
# self.model().itemFromIndex(s_idx).text()
# for s_idx in self.selected_in_column(column)
# )
menu.addAction( menu.addAction(
_("details").format(column_title), _("details").format(column_title),
lambda: self.show_transaction(selected_keys), lambda: self.show_transaction(selected_keys),
).setEnabled(len(selected_keys) < 2) ).setEnabled(len(selected_keys) == 1)
menu.addAction(
_("sign").format(column_title),
lambda: self.sign_transactions(selected_keys),
).setEnabled(
any(_can_sign(self.will.get(k)) for k in selected_keys)
)
menu.addAction(
_("broadcast").format(column_title),
lambda: self.broadcast_transactions(selected_keys),
).setEnabled(
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
)
menu.addAction( menu.addAction(
_("check ").format(column_title), _("check ").format(column_title),
lambda: self.check_transactions(selected_keys), lambda: self.check_transactions(selected_keys),
) )
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
try: menu.addSeparator()
self.importaction = self.menu.addAction( menu.addAction(
_("Import"), self.import_will _("copy id").format(column_title),
lambda: self.copy_txids(selected_keys),
)
menu.addAction(
_("copy").format(column_title),
lambda: self.copy_tx_hexes(selected_keys),
)
menu.addAction(
_("merge from txn").format(column_title),
lambda: self.merge_from_txn(),
) )
except Exception:
pass
menu.addSeparator() menu.addSeparator()
menu.addAction( menu.addAction(
_("delete").format(column_title), lambda: self.delete(selected_keys) _("delete").format(column_title), lambda: self.delete(selected_keys)
).setEnabled(
any(_can_delete(self.will.get(k)) for k in selected_keys)
) )
menu.exec(self.viewport().mapToGlobal(position)) menu.exec(self.viewport().mapToGlobal(position))
def is_deletable(self, key):
"""True if the will transaction ``key`` may be deleted.
Deletion is only allowed for transactions that are NOT valid or NOT
fully signed (invalidated/replaced/mempool/confirmed items, or
unsigned/partially signed ones). Valid and complete transactions are
kept (deleting them would silently drop a committed inheritance).
"""
return _can_delete(self.will.get(key))
def delete(self, selected_keys): def delete(self, selected_keys):
for key in selected_keys: keys = [k for k in selected_keys if self.is_deletable(k)]
for key in keys:
del self.will[key] del self.will[key]
try: try:
del self.bal_window.willitems[key] del self.bal_window.willitems[key]
@@ -338,6 +419,75 @@ class PreviewList(MyTreeView, MessageBoxMixin):
pass pass
self.update() self.update()
def sign_transactions(self, selected_keys):
"""Sign all selected transactions that are not fully signed yet."""
keys = [
k for k in selected_keys
if _can_sign(self.will.get(k))
]
if keys:
self.bal_window.ask_password_and_sign_transactions(
callback=self.update, txids=keys
)
def broadcast_transactions(self, selected_keys):
"""Force-broadcast all selected fully-signed transactions.
``force=True`` makes the will-executors re-accept transactions that
were already pushed before.
"""
keys = [
k for k in selected_keys
if _can_broadcast(self.will.get(k))
]
if keys:
self.bal_window.broadcast_transactions(force=True, txids=keys)
self.update()
def copy_txids(self, selected_keys):
"""Copy the selected transaction IDs to the clipboard (one per line)."""
self.place_text_on_clipboard(
"\n".join(selected_keys), title=_("Transaction IDs")
)
def copy_tx_hexes(self, selected_keys):
"""Copy the selected transactions (serialised hex) to the clipboard."""
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
self.place_text_on_clipboard(hexes, title=_("Transactions"))
def merge_from_txn(self):
"""Merge a transaction read from the clipboard, or from a file if the
clipboard does not contain a valid transaction.
"""
tx = None
try:
tx = tx_from_any(QApplication.clipboard().text())
except Exception:
tx = None
if tx is None:
filename = getOpenFileName(
parent=self.bal_window.window,
title=_("Open transaction file"),
filter="All files (*)",
config=self.bal_window.window.config,
)
if not filename:
return
try:
with open(filename, "r") as f:
data = f.read()
tx = tx_from_any(data)
except Exception as e:
self.bal_window.show_error(
_("Invalid transaction file: {}").format(e)
)
return
try:
self.bal_window.merge_single_transaction(tx)
except Exception as e:
self.bal_window.show_error(str(e))
self.update()
def check_transactions(self, selected_keys): def check_transactions(self, selected_keys):
wout = {} wout = {}
for k in selected_keys: for k in selected_keys:
@@ -385,8 +535,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if bal_tx.we: if bal_tx.we:
we = bal_tx.we["url"] we = bal_tx.we["url"]
labels[self.Columns.WILLEXECUTOR] = we labels[self.Columns.WILLEXECUTOR] = we
status = bal_tx.status status = bal_tx.status + signature_suffix(bal_tx)
if len(bal_tx.status) > 53: if len(status) > 53:
status = "...{}".format(status[-50:]) status = "...{}".format(status[-50:])
labels[self.Columns.STATUS] = status labels[self.Columns.STATUS] = status
# Dedicated, always-readable label describing whether the inheritance # Dedicated, always-readable label describing whether the inheritance
@@ -462,9 +612,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
menu.addAction(_("Prepare"), self.build_transactions) menu.addAction(_("Prepare"), self.build_transactions)
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog) menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions) menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
menu.addAction(_("Export"), self.export_will) export_menu = menu.addMenu(_("Export"))
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get(): export_menu.addAction(_("All"), self.export_will)
self.importaction = menu.addAction(_("Import"), self.import_will) export_menu.addAction(_("Valid"), self.export_will_valid)
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
menu.addAction(_("Import"), self.import_will_into_details)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast) menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check) menu.addAction(_("Check"), self.check)
menu.addAction(_("Invalidate"), self.invalidate_will) menu.addAction(_("Invalidate"), self.invalidate_will)
@@ -536,8 +689,37 @@ class PreviewList(MyTreeView, MessageBoxMixin):
self.bal_window.export_will() self.bal_window.export_will()
self.update() self.update()
def import_will(self): def export_will_valid(self):
self.bal_window.import_will() """Export only the will items that are valid."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID")
}
if not subset:
self.show_message(_("No valid will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def export_will_valid_incomplete(self):
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
}
if not subset:
self.show_message(_("No valid, incomplete will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def import_will_into_details(self):
self.bal_window.import_will_into_details()
def merge_will(self):
self.bal_window.merge_will_ui()
def ask_password_and_sign_transactions(self): def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update) self.bal_window.ask_password_and_sign_transactions(callback=self.update)
@@ -756,6 +938,7 @@ class WillExecutorListWidget(MyTreeView):
idx = self.indexAt(position) idx = self.indexAt(position)
column = idx.column() or self.Columns.URL column = idx.column() or self.Columns.URL
selected_keys = [] selected_keys = []
sel_key = None
for s_idx in self.selected_in_column(self.Columns.URL): for s_idx in self.selected_in_column(self.Columns.URL):
item = self.model().itemFromIndex(s_idx) item = self.model().itemFromIndex(s_idx)
# Use the FULL url stored in the key role, NOT item.data(0): the # Use the FULL url stored in the key role, NOT item.data(0): the
@@ -771,6 +954,15 @@ class WillExecutorListWidget(MyTreeView):
# self.model().itemFromIndex(s_idx).text() # self.model().itemFromIndex(s_idx).text()
# for s_idx in self.selected_in_column(column) # for s_idx in self.selected_in_column(column)
# ) # )
# When exactly ONE cell is selected, offer "Copy" to copy the value
# of that cell to the clipboard (e.g. a single url / address / fee).
# This list has no ``main_window``, so use QApplication directly.
if len(self.selectionModel().selectedIndexes()) == 1:
cell_value = self.model().itemFromIndex(idx).text()
menu.addAction(
_("Copy"),
lambda: QApplication.clipboard().setText(cell_value),
)
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]): if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
menu.addAction( menu.addAction(
_("deselect").format(column_title), _("deselect").format(column_title),
@@ -929,6 +1121,17 @@ class WillExecutorListWidget(MyTreeView):
pass pass
else: else:
items.append(QStandardItem(e)) items.append(QStandardItem(e))
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
grey = QColor("#808080")
for item in items:
font = item.font()
font.setItalic(True)
item.setFont(font)
item.setForeground(grey)
items[self.Columns.SELECTED].setEditable(False) items[self.Columns.SELECTED].setEditable(False)
items[self.Columns.URL].setEditable(True) items[self.Columns.URL].setEditable(True)
items[self.Columns.ADDRESS].setEditable(True) items[self.Columns.ADDRESS].setEditable(True)
@@ -1008,13 +1211,44 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
b.clicked.connect(self.import_file) b.clicked.connect(self.import_file)
buttonbox.addWidget(b) buttonbox.addWidget(b)
b = QPushButton(_("Export")) def _menu_button(label):
b.clicked.connect(self.export_file) btn = QToolButton()
buttonbox.addWidget(b) btn.setText(_(label))
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
buttonbox.addWidget(btn)
return btn
b = QPushButton(_("Ping All")) export_btn = _menu_button("Export")
b.clicked.connect(self.update_willexecutors) export_menu = QMenu(export_btn)
buttonbox.addWidget(b) export_menu.addAction(_("Export all"), lambda: self.export_file())
export_menu.addAction(
_("Export selected"), lambda: self.export_file(subset="selected")
)
export_menu.addAction(
_("Export only valid"), lambda: self.export_file(subset="valid")
)
export_btn.setMenu(export_menu)
ping_btn = _menu_button("Ping All")
ping_menu = QMenu(ping_btn)
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
ping_menu.addAction(
_("Ping selected"), lambda: self.ping_selected_willexecutors()
)
ping_btn.setMenu(ping_menu)
select_btn = _menu_button("Select All")
select_menu = QMenu(select_btn)
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
select_menu.addAction(
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
)
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
select_menu.addAction(
_("Deselect only invalid"),
lambda: self.set_select_all(False, only_valid=True),
)
select_btn.setMenu(select_menu)
vbox.addLayout(buttonbox) vbox.addLayout(buttonbox)
# self.will_executor_list_widget.update() # self.will_executor_list_widget.update()
@@ -1135,6 +1369,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
add_another_btn.clicked.connect(add_another) add_another_btn.clicked.connect(add_another)
else: else:
self._add_another = False self._add_another = False
add_another_btn = None
row = 0 row = 0
grid.addWidget(QLabel(_("URL")), row, 0) grid.addWidget(QLabel(_("URL")), row, 0)
@@ -1227,13 +1462,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
self.bal_window.download_list(self.bal_window.willexecutors, on_success) self.bal_window.download_list(self.bal_window.willexecutors, on_success)
def export_file(self, path): def export_file(self, subset=None):
data = self.export_data(subset)
if subset and not data:
self.show_message(_("No will-executor matches the selected filter"))
return
export_meta_gui( export_meta_gui(
self.bal_window.window, "willexecutors.json", self.export_json_file self.bal_window.window,
"willexecutors.json",
partial(self.export_json_file, subset=subset),
) )
def export_json_file(self, path): def export_data(self, subset=None):
write_json_file(path, self.willexecutors_list) data = self.willexecutors_list
if subset == "selected":
data = {
url: we
for url, we in data.items()
if Willexecutors.is_selected(we)
}
elif subset == "valid":
valid = self._validity()
data = {
url: we for url, we in data.items() if valid.get(url, False)
}
return data
def export_json_file(self, path, subset=None):
write_json_file(path, self.export_data(subset))
def _validity(self):
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
dust = self.bal_window.window.wallet.dust_threshold()
return {
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
for url, we in self.willexecutors_list.items()
}
def _selected_willexecutors(self):
return {
url: we
for url, we in self.willexecutors_list.items()
if Willexecutors.is_selected(we)
}
def set_select_all(self, select, only_valid=False):
"""Apply a bulk selection across all will-executors.
``select=True`` selects all (or only the valid ones when
``only_valid=True``, deselecting the invalid ones); ``select=False``
deselects all (or only the invalid ones when ``only_valid=True``,
leaving the valid ones selected).
"""
valid = self._validity() if only_valid else None
_apply_select_all(self.willexecutors_list, select, valid)
self.save_willexecutors()
def ping_selected_willexecutors(self):
wes = self._selected_willexecutors()
if not wes:
self.show_message(_("No will-executor is selected"))
return
self.update_willexecutors(wes)
def import_file(self): def import_file(self):
import_meta_gui( import_meta_gui(

View File

@@ -15,14 +15,17 @@ and cached in ``self.bal_windows``.
""" """
from electrum.gui.qt.main_window import StatusBarButton from electrum.gui.qt.main_window import StatusBarButton
from PyQt6.QtWidgets import QLayout
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import ( # underscore names are not re-exported by "import *"
from .common import read_QIcon_from_bytes _,
_logger,
read_QIcon_from_bytes,
)
from .dialogs import BalDialog
from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit from .widgets import BalCheckBox, BalLineEdit, BalSpinBox, BalTextEdit
from .window import BalWindow from .window import BalWindow
from .dialogs import BalDialog
from PyQt6.QtWidgets import QLayout
def _window_key(window): def _window_key(window):
@@ -88,9 +91,11 @@ class Plugin(BalPlugin):
except Exception: except Exception:
return [] return []
try: try:
from electrum.gui.qt.plugins_dialog import PluginsDialog from electrum.gui.qt.plugins_dialog import (
PluginsDialog as plugins_dialog, # noqa: N813
)
except Exception: except Exception:
PluginsDialog = None plugins_dialog = None
app = QApplication.instance() app = QApplication.instance()
if app is None: if app is None:
return [] return []
@@ -106,7 +111,7 @@ class Plugin(BalPlugin):
for w in app.topLevelWidgets(): for w in app.topLevelWidgets():
try: try:
is_match = False is_match = False
if PluginsDialog is not None and isinstance(w, PluginsDialog): if plugins_dialog is not None and isinstance(w, plugins_dialog):
is_match = True is_match = True
elif type(w).__name__ == "PluginsDialog": elif type(w).__name__ == "PluginsDialog":
is_match = True is_match = True
@@ -142,17 +147,17 @@ class Plugin(BalPlugin):
each is guarded independently. each is guarded independently.
""" """
try: try:
from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
except Exception: except Exception:
QDialog = None qdialog = None
# 1) reject() / done(): the reliable way to end an exec() modal loop. # 1) reject() / done(): the reliable way to end an exec() modal loop.
if QDialog is not None and isinstance(d, QDialog): if qdialog is not None and isinstance(d, qdialog):
try: try:
d.reject() d.reject()
except Exception as e: except Exception as e:
_logger.debug("reject() failed: {}".format(e)) _logger.debug("reject() failed: {}".format(e))
try: try:
d.done(QDialog.DialogCode.Rejected) d.done(qdialog.DialogCode.Rejected)
except Exception as e: except Exception as e:
_logger.debug("done() failed: {}".format(e)) _logger.debug("done() failed: {}".format(e))
# 2) close(): covers non-QDialog top-levels and is a harmless extra. # 2) close(): covers non-QDialog top-levels and is a harmless extra.
@@ -172,9 +177,9 @@ class Plugin(BalPlugin):
it and closes it themselves (it must not linger in the background). it and closes it themselves (it must not linger in the background).
""" """
try: try:
from PyQt6.QtCore import QTimer from PyQt6.QtCore import QTimer as qtimer # noqa: N813
except Exception: except Exception:
QTimer = None qtimer = None
# Schedule of retry delays (ms) measured from each call. # Schedule of retry delays (ms) measured from each call.
retry_delays = [400, 800, 1500] retry_delays = [400, 800, 1500]
dialogs = Plugin._find_plugins_manager_dialogs() dialogs = Plugin._find_plugins_manager_dialogs()
@@ -190,8 +195,8 @@ class Plugin(BalPlugin):
if not still_open: if not still_open:
_logger.info("plugins dialog closed successfully") _logger.info("plugins dialog closed successfully")
return return
if attempt < len(retry_delays) and QTimer is not None: if attempt < len(retry_delays) and qtimer is not None:
QTimer.singleShot( qtimer.singleShot(
retry_delays[attempt], retry_delays[attempt],
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1), lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
) )
@@ -436,6 +441,13 @@ class Plugin(BalPlugin):
# persisted NUM_REMINDERS config (default 3), with a range of 1..5. # persisted NUM_REMINDERS config (default 3), with a range of 1..5.
heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5) heir_num_reminders = BalSpinBox(self.NUM_REMINDERS, minimum=1, maximum=5)
# Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
# a single will-executor. If a will-executor charges more, the will
# will not be built. Default 500,000 satoshi (0.005 BTC).
heir_max_willexecutor_fee = BalSpinBox(
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
)
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR # "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
# config (default ON, see plugin_base.py), the SAME config used by the # config (default ON, see plugin_base.py), the SAME config used by the
# checkbox inside the "Build your will" wizard's will-executor download # checkbox inside the "Build your will" wizard's will-executor download
@@ -498,8 +510,10 @@ class Plugin(BalPlugin):
lbl_event_description, edit_event_description, help_event_description, lbl_event_description, edit_event_description, help_event_description,
lbl_calendar_app, edit_calendar_app, help_calendar_app, lbl_calendar_app, edit_calendar_app, help_calendar_app,
lbl_auto_sign, heir_auto_sign, help_auto_sign, lbl_auto_sign, heir_auto_sign, help_auto_sign,
lbl_save_history, heir_save_history, help_save_history,
lbl_history_label, edit_history_label, help_history_label,
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10, reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
reset_btn_auto_sign): reset_btn_11, reset_btn_12, reset_btn_auto_sign):
w.setVisible(not basic) w.setVisible(not basic)
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on # Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
# a real USER TYPE change (not inside update_all/CHECK), so pressing # a real USER TYPE change (not inside update_all/CHECK), so pressing
@@ -525,6 +539,20 @@ class Plugin(BalPlugin):
edit_calendar_app = BalLineEdit(self.CALENDAR_APP) edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
edit_calendar_app.setMinimumWidth(360) edit_calendar_app.setMinimumWidth(360)
# "Save inheritance transactions in wallet history" checkbox + label
# field (History persistence). When the checkbox is ON, the valid will
# transactions are saved into the wallet's LOCAL history (the History
# tab) after each check, each tagged with the label below. The label
# field is disabled while the checkbox is off, so the user cannot set a
# label for a feature that is not active.
def on_save_history_change():
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
heir_save_history = BalCheckBox(self.SAVE_HISTORY, on_click=on_save_history_change)
edit_history_label = BalLineEdit(self.HISTORY_LABEL)
edit_history_label.setMinimumWidth(360)
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
def _make_reset_btn(cfg, widget, kind): def _make_reset_btn(cfg, widget, kind):
"""Return a small ``↺`` button that resets a single setting.""" """Return a small ``↺`` button that resets a single setting."""
btn = QPushButton("\u21ba") btn = QPushButton("\u21ba")
@@ -636,13 +664,28 @@ class Plugin(BalPlugin):
), ),
) )
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3) grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
# will-executor. Visible to all users (BASIC and ADVANCED).
add_widget(
grid,
"Max Will-Executor Fee (satoshi)",
heir_max_willexecutor_fee,
5,
(
"Maximum fee (in satoshi) allowed to be paid to a single "
"will-executor. If a will-executor charges more than this, "
"the will will not be built.\n"
"Default: 500,000 satoshi (0.005 BTC)."
),
)
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 5, 3)
# User Type selector placed BEFORE the advanced-only settings so the # User Type selector placed BEFORE the advanced-only settings so the
# user chooses basic/advanced first, then sees the relevant options. # user chooses basic/advanced first, then sees the relevant options.
add_widget( add_widget(
grid, grid,
"User Type", "User Type",
user_type_combo, user_type_combo,
5, 6,
( (
"Choose how much detail the plugin shows.\n\n" "Choose how much detail the plugin shows.\n\n"
"BASIC: simplified interface, safe configuration for most " "BASIC: simplified interface, safe configuration for most "
@@ -653,7 +696,7 @@ class Plugin(BalPlugin):
"editable." "editable."
), ),
) )
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 3) grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3)
# Number of reminders, event summary and event description are visible # Number of reminders, event summary and event description are visible
# only in ADVANCED mode. In BASIC mode the factory defaults are always # only in ADVANCED mode. In BASIC mode the factory defaults are always
# used and these settings are hidden. # used and these settings are hidden.
@@ -662,11 +705,11 @@ class Plugin(BalPlugin):
"How many reminder alarms the exported calendar (.ics) event " "How many reminder alarms the exported calendar (.ics) event "
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode." "contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0) grid.addWidget(_hide_if_basic(lbl_num_reminders), 7, 0)
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1) grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2) grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin") reset_btn_6 = _make_reset_btn(self.NUM_REMINDERS, heir_num_reminders, "spin")
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3) grid.addWidget(_hide_if_basic(reset_btn_6), 7, 3)
lbl_event_summary = QLabel(_("Event summary")) lbl_event_summary = QLabel(_("Event summary"))
help_event_summary = HelpButton( help_event_summary = HelpButton(
@@ -676,11 +719,11 @@ class Plugin(BalPlugin):
" $heirs_complete: list of heirs name,address,amount\n" " $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode." "Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0) grid.addWidget(_hide_if_basic(lbl_event_summary), 8, 0)
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1) grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2) grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line") reset_btn_7 = _make_reset_btn(self.EVENT_SUMMARY, edit_event_summary, "line")
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3) grid.addWidget(_hide_if_basic(reset_btn_7), 8, 3)
lbl_event_description = QLabel(_("Event description")) lbl_event_description = QLabel(_("Event description"))
help_event_description = HelpButton( help_event_description = HelpButton(
@@ -690,11 +733,11 @@ class Plugin(BalPlugin):
" $heirs_complete: list of heirs name,address,amount\n" " $heirs_complete: list of heirs name,address,amount\n"
"Only used in ADVANCED mode." "Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0) grid.addWidget(_hide_if_basic(lbl_event_description), 9, 0)
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1) grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
grid.addWidget(_hide_if_basic(help_event_description), 8, 2) grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text") reset_btn_8 = _make_reset_btn(self.EVENT_DESCRIPTION, edit_event_description, "text")
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 3) grid.addWidget(_hide_if_basic(reset_btn_8), 9, 3)
# Welist server URL: shown only in ADVANCED mode. In BASIC mode the # Welist server URL: shown only in ADVANCED mode. In BASIC mode the
# factory default is always used and the setting is hidden. # factory default is always used and the setting is hidden.
lbl_welist_server = QLabel(_("Welist Server URL")) lbl_welist_server = QLabel(_("Welist Server URL"))
@@ -702,11 +745,11 @@ class Plugin(BalPlugin):
"URL of the server that provides the will-executor list. " "URL of the server that provides the will-executor list. "
"Only available in ADVANCED mode." "Only available in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0) grid.addWidget(_hide_if_basic(lbl_welist_server), 10, 0)
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1) grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2) grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line") reset_btn_9 = _make_reset_btn(self.WELIST_SERVER, edit_welist_server, "line")
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3) grid.addWidget(_hide_if_basic(reset_btn_9), 10, 3)
lbl_calendar_app = QLabel(_("Calendar app command")) lbl_calendar_app = QLabel(_("Calendar app command"))
help_calendar_app = HelpButton( help_calendar_app = HelpButton(
@@ -714,11 +757,42 @@ class Plugin(BalPlugin):
"Leave empty to use the system default (xdg-open/open/start).\n" "Leave empty to use the system default (xdg-open/open/start).\n"
"Only used in ADVANCED mode." "Only used in ADVANCED mode."
) )
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0) grid.addWidget(_hide_if_basic(lbl_calendar_app), 11, 0)
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1) grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 2) grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line") reset_btn_10 = _make_reset_btn(self.CALENDAR_APP, edit_calendar_app, "line")
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 3) grid.addWidget(_hide_if_basic(reset_btn_10), 11, 3)
# Save-in-history toggle and history label: advanced-only rows. The
# label field is disabled while the checkbox is off (see
# on_save_history_change above).
lbl_save_history = QLabel(_("Save inheritance transactions in history"))
help_save_history = HelpButton(
"After each check, save the valid will transactions into the "
"wallet's local history (the History tab), each with a label.\n"
"The label may contain the variable:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_save_history), 12, 0)
grid.addWidget(_hide_if_basic(heir_save_history), 12, 1)
grid.addWidget(_hide_if_basic(help_save_history), 12, 2)
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
grid.addWidget(_hide_if_basic(reset_btn_11), 12, 3)
lbl_history_label = QLabel(_("History label"))
help_history_label = HelpButton(
"Label applied to the will transactions saved into the wallet's "
"local history.\n"
"Variables:\n"
" {willexecutor}: replaced with the will-executor URL of the item\n"
"Only used in ADVANCED mode."
)
grid.addWidget(_hide_if_basic(lbl_history_label), 13, 0)
grid.addWidget(_hide_if_basic(edit_history_label), 13, 1)
grid.addWidget(_hide_if_basic(help_history_label), 13, 2)
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
grid.addWidget(_hide_if_basic(reset_btn_12), 13, 3)
# NOTE: the ADVANCED-only widgets above have ALREADY been given their # NOTE: the ADVANCED-only widgets above have ALREADY been given their
# correct initial visibility inline (via _hide_if_basic) BEFORE being # correct initial visibility inline (via _hide_if_basic) BEFORE being
@@ -727,12 +801,12 @@ class Plugin(BalPlugin):
# the Windows relayout flicker. Do NOT reintroduce a post-hoc # the Windows relayout flicker. Do NOT reintroduce a post-hoc
# setVisible() loop here. # setVisible() loop here.
grid.addWidget(heir_repush, 11, 0) grid.addWidget(heir_repush, 14, 0)
grid.addWidget( grid.addWidget(
HelpButton( HelpButton(
"Broadcast all transactions to willexecutors including those already pushed" "Broadcast all transactions to willexecutors including those already pushed"
), ),
11, 14,
2, 2,
) )
@@ -761,10 +835,13 @@ class Plugin(BalPlugin):
(self.EDITABLE_DATES, heir_editable_dates, "check"), (self.EDITABLE_DATES, heir_editable_dates, "check"),
(self.NUM_REMINDERS, heir_num_reminders, "spin"), (self.NUM_REMINDERS, heir_num_reminders, "spin"),
(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), (self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"),
(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"),
(self.EVENT_SUMMARY, edit_event_summary, "line"), (self.EVENT_SUMMARY, edit_event_summary, "line"),
(self.EVENT_DESCRIPTION, edit_event_description, "text"), (self.EVENT_DESCRIPTION, edit_event_description, "text"),
(self.WELIST_SERVER, edit_welist_server, "line"), (self.WELIST_SERVER, edit_welist_server, "line"),
(self.CALENDAR_APP, edit_calendar_app, "line"), (self.CALENDAR_APP, edit_calendar_app, "line"),
(self.SAVE_HISTORY, heir_save_history, "check"),
(self.HISTORY_LABEL, edit_history_label, "line"),
] ]
for cfg, widget, kind in resets: for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config. # Persist the default value back into the Electrum config.
@@ -785,6 +862,10 @@ class Plugin(BalPlugin):
widget.setCurrentIndex( widget.setCurrentIndex(
1 if str(cfg.default).lower() == "advanced" else 0 1 if str(cfg.default).lower() == "advanced" else 0
) )
# Re-sync the history-label field's enabled state after a reset: the
# reset restores SAVE_HISTORY to its default, so the field must
# follow the (default) checkbox state again.
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
# Refresh the open BAL windows so any dependent view (e.g. the # Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are) # editable-dates state is not in this list, but hide filters are)
# reflects the reset values. # reflects the reset values.

View File

@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
return "#e83845" # red - failed to push to will-executor return "#e83845" # red - failed to push to will-executor
elif will_item.get_status("PUSHED"): elif will_item.get_status("PUSHED"):
return "#73f3c8" # teal - pushed to will-executor return "#73f3c8" # teal - pushed to will-executor
elif will_item.get_status("PARTIALLY_SIGNED"):
return "#ffb347" # amber - some signatures present, more needed
elif will_item.get_status("COMPLETE"): elif will_item.get_status("COMPLETE"):
return "#2bc8ed" # blue - signed return "#2bc8ed" # blue - signed
else: else:
return _DEFAULT_COLOR return _DEFAULT_COLOR
def signature_suffix(will_item) -> str:
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
Used by the transaction list and the detail view to show how many of the
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
transaction carrying one signature. Returns ``""`` for signed transactions
or when the required count is unknown (no descriptor available yet).
"""
if will_item.get_status("COMPLETE"):
return ""
required = int(getattr(will_item, "sigs_required", 0) or 0)
added = int(getattr(will_item, "sigs_have", 0) or 0)
if not required:
return ""
return " ({}/{})".format(added, required)
def server_status_text(will_item) -> str: def server_status_text(will_item) -> str:
"""Return a short, human-readable label describing the state of a will """Return a short, human-readable label describing the state of a will
item on the will-executor servers (the online inheritance backup). item on the will-executor servers (the online inheritance backup).

View File

@@ -18,89 +18,21 @@ Contents:
* WillWidget - single will-tx box * WillWidget - single will-tx box
""" """
from typing import TYPE_CHECKING
from ...core.input_rules import (
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
)
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
from .calendar import BalCalendar, BalCalendarButton
if TYPE_CHECKING:
def compute_reminder_offsets(days, count): from .window import BalWindow
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
@@ -214,39 +146,12 @@ class BalTxFeesWidget(QWidget):
class _LockTimeEditor: class _LockTimeEditor(LockTimeEditor):
min_allowed_value = NLOCKTIME_MIN """Qt-side locktime editor base.
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is platform-dependent).
# see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
this is just the mixin that lets Qt widgets reuse them.
"""
class BalTimeEditWidget(QWidget, _LockTimeEditor): class BalTimeEditWidget(QWidget, _LockTimeEditor):
@@ -567,51 +472,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.isyears = False self.isyears = False
self.time_edit = time_edit self.time_edit = time_edit
@staticmethod
def replace_str(text):
"""Strip the relative-time suffixes (d/y) from the text.
Only days ("d") and years ("y") are supported. The block-height
suffix ("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def checkbdy(self, s, pos, appendix):
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = self.replace_str(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def numbify(self): def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted. # Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always # The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed. # UNIX timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition() pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars])) s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
s = "".join([i for i in text if i in chars]) self.isdays = isdays
self.isdays = False self.isyears = isyears
self.isyears = False
pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y")
if "d" in s:
self.isdays = True
if "y" in s:
self.isyears = True
if self.isdays:
s = self.replace_str(s) + "d"
if self.isyears:
s = self.replace_str(s) + "y"
self.blockSignals(True) self.blockSignals(True)
self.setText(s) self.setText(s)
self.blockSignals(False) self.blockSignals(False)
@@ -690,7 +560,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
return return
try: try:
x = int(x) x = int(x)
except Exception as e: except Exception:
x = QDateTime.currentDateTime().timestamp() x = QDateTime.currentDateTime().timestamp()
finally: finally:
# Use the overflow-safe converter: on Windows datetime.fromtimestamp # Use the overflow-safe converter: on Windows datetime.fromtimestamp
@@ -1068,11 +938,12 @@ class WillSettingsWidget(QWidget):
sees several distinct appointments in their calendar. sees several distinct appointments in their calendar.
The number of events is read from the NUM_REMINDERS setting (default 3, The number of events is read from the NUM_REMINDERS setting (default 3,
capped at 5 by the settings dialog). Their dates are computed with capped at 5 by the settings dialog). Their dates are computed by
``compute_reminder_offsets``: the offsets are spread uniformly across the ``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
check-alive period and the LAST event always falls one day before the across the check-alive period; the LAST event always falls one day
delivery deadline (locktime). If the period is shorter than the before the delivery deadline / locktime). If the period is shorter than
requested number of reminders, at most one event per day is produced. the requested number of reminders, at most one event per day is
produced.
Each event: Each event:
* is placed on ``locktime - offset`` days (its own visible date); * is placed on ``locktime - offset`` days (its own visible date);
@@ -1087,103 +958,23 @@ class WillSettingsWidget(QWidget):
path the user picks in the save dialog (default name "BAL_will_event.ics" path the user picks in the save dialog (default name "BAL_will_event.ics"
on the Desktop). on the Desktop).
""" """
now = BalCalendar.format_time(datetime.now()) # The .ics content (offsets, escaping, folding, VEVENT layout) is built
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
# locktime = delivery deadline. It is exposed by the date widget as # reminder falls in the future it returns None (ToDo #2) and the user is
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum # warned instead of getting an empty-looking file.
# transaction locktime, so the calendar uses the correct delivery date. ics_content = self._ics_provider()
locktime = self.widgets["locktime"].alarm if not ics_content:
self.bal_window.show_warning(
# BASIC vs ADVANCED reminder strategy. _(
# "No reminders were saved: the delivery date is too "
# In ADVANCED mode the reminders are spread uniformly across the "close (or already passed)"
# check-alive (threshold) period, ending one day before the deadline.
#
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
# user (it stays at an arbitrary default), so spreading reminders over it
# is meaningless. The owner asked that, in BASIC, the calendar simply
# saves the inheritance delivery date with three fixed reminders: 30 days
# before, 10 days before and 1 day before. We also drop any fixed offset
# that would fall in the past (a reminder before "today" is useless), so
# a short-dated will still gets the reminders that are still in the
# future.
if self.bal_window.bal_plugin.is_basic_mode():
# Whole days from now until the delivery date. Fixed offsets (30, 10,
# 1 day before) are applied by basic_reminder_offsets, which also
# drops any offset that would fall in the past.
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
# ADVANCED: spread reminders over the check-alive period as before.
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminders the user asked for (default 3 if unreadable).
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception:
count = 3
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
offsets = compute_reminder_offsets(days, count)
# Per-event heir details and the shared description/summary templates.
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
for heir in self.bal_window.heirs
) )
# BASIC mode: use factory defaults (the hidden settings are ignored).
# ADVANCED mode: use the user-configured values.
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
) )
return
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the # Keep the generated .ics in a temp file; it is copied to the path the
# user picks below. # user picks below.
self.temp_path = BalCalendar.write_temp_ics(ics_content) self.temp_path = write_temp_ics(ics_content)
# Group D / D1b: always ask the user WHERE to save the .ics file (the # Group D / D1b: always ask the user WHERE to save the .ics file (the
# plugin no longer tries to open it with a calendar app). The save # plugin no longer tries to open it with a calendar app). The save
@@ -1236,26 +1027,31 @@ class WillSettingsWidget(QWidget):
def _ics_provider(self): def _ics_provider(self):
"""Return the .ics content for the current locktime/threshold values. """Return the .ics content for the current locktime/threshold values.
Used by :class:`BalCalendarButton` as its content provider. Used by :class:`BalCalendarButton` as its content provider. The whole
document (reminder offsets, escaping, folding, VEVENT layout) is built
by the pure :func:`bal.core.reminders.build_ics_reminders`.
""" """
from datetime import datetime, timedelta
try: try:
locktime = self.widgets["locktime"].alarm locktime = self.widgets["locktime"].alarm
if self.bal_window.bal_plugin.is_basic_mode(): basic_mode = self.bal_window.bal_plugin.is_basic_mode()
days_to_deadline = (locktime - datetime.now()).days if basic_mode:
offsets = basic_reminder_offsets(days_to_deadline) # BASIC mode: use factory defaults (the hidden settings are
# ignored) and the fixed 30/10/1 offsets.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else: else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
threshold = self.widgets["threshold"].alarm threshold = self.widgets["threshold"].alarm
days = (locktime - threshold).days
try: try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get()) num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception: except Exception:
count = 3 num_reminders = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
heirs_details = "\r\n".join( heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, " f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1263,50 +1059,17 @@ class WillSettingsWidget(QWidget):
for heir in self.bal_window.heirs for heir in self.bal_window.heirs
) )
if self.bal_window.bal_plugin.is_basic_mode(): return build_ics_reminders(
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default locktime=locktime,
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default basic_mode=basic_mode,
else: description=raw_description,
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get() summary=raw_summary,
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get() wallet_name=str(self.bal_window.wallet),
heirs_details=heirs_details,
event_description = BalCalendar.ical_escape( version=self.bal_window.bal_plugin.version,
f"{raw_description}" num_reminders=num_reminders,
.replace("$wallet_name", str(self.bal_window.wallet)) threshold=threshold,
.replace("$heirs_complete", heirs_details)
) )
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{BalPlugin.__version__}",
]
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"
except Exception as e: except Exception as e:
_logger.error(f"failed to generate .ics: {e}") _logger.error(f"failed to generate .ics: {e}")
return None return None
@@ -1349,40 +1112,20 @@ class PercAmountEdit(BTCAmountEdit):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount) super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self): def numbify(self):
# The text sanitisation lives in bal.core.input_rules.
text = self.text().strip() text = self.text().strip()
if text == "!": if text == "!":
self.shortcut.emit() self.shortcut.emit()
return return
pos = self.cursorPosition() pos = self.cursorPosition()
chars = "0123456789%" s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
chars += DECIMAL_POINT
s = "".join([i for i in text if i in chars])
if "%" in s:
self.is_perc = True
s = s.replace("%", "")
else:
self.is_perc = False
if DECIMAL_POINT in s:
p = s.find(DECIMAL_POINT)
s = s.replace(DECIMAL_POINT, "")
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
if self.is_perc:
s += "%"
self.setText(s) self.setText(s)
self.setModified(self.hasFocus()) self.setModified(self.hasFocus())
self.setCursorPosition(pos) self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]: def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try: return parse_perc_amount(text, DECIMAL_POINT)
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
def _get_text_from_amount(self, amount): def _get_text_from_amount(self, amount):
out = super()._get_text_from_amount(amount) out = super()._get_text_from_amount(amount)
@@ -1395,15 +1138,15 @@ class PercAmountEdit(BTCAmountEdit):
if self.base_unit: if self.base_unit:
panel = QStyleOptionFrame() panel = QStyleOptionFrame()
self.initStyleOption(panel) self.initStyleOption(panel)
textRect = self.style().subElementRect( text_rect = self.style().subElementRect(
QStyle.SubElement.SE_LineEditContents, panel, self QStyle.SubElement.SE_LineEditContents, panel, self
) )
textRect.adjust(2, 0, -10, 0) text_rect.adjust(2, 0, -10, 0)
painter = QPainter(self) painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color()) painter.setPen(ColorScheme.GRAY.as_color())
if len(self.text()) == 0: if len(self.text()) == 0:
painter.drawText( painter.drawText(
textRect, text_rect,
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter), int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
self.base_unit() + " or perc value", self.base_unit() + " or perc value",
) )
@@ -1482,11 +1225,11 @@ class BalSpinBox(QSpinBox):
class WillWidget(QWidget): class WillWidget(QWidget):
def __init__(self, father=None, parent=None): def __init__(self, father=None, parent=None, will=None):
super().__init__() super().__init__()
vlayout = QVBoxLayout() vlayout = QVBoxLayout()
self.setLayout(vlayout) self.setLayout(vlayout)
self.will = parent.bal_window.willitems self.will = will if will is not None else parent.bal_window.willitems
self._bal_parent = parent self._bal_parent = parent
for w in self.will: for w in self.will:
if ( if (
@@ -1513,7 +1256,10 @@ class WillWidget(QWidget):
willpushbutton = QPushButton(w) willpushbutton = QPushButton(w)
willpushbutton.clicked.connect( willpushbutton.clicked.connect(
partial(self._bal_parent.bal_window.show_transaction, txid=w) partial(
self._bal_parent.bal_window.show_transaction,
tx=self.will[w].tx,
)
) )
detaillayout.addWidget(willpushbutton) detaillayout.addWidget(willpushbutton)
locktime = str(BalTimestamp(self.will[w].tx.locktime)) locktime = str(BalTimestamp(self.will[w].tx.locktime))
@@ -1535,7 +1281,9 @@ class WillWidget(QWidget):
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3) fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)" fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
detaillayout.addWidget(qlabel("Transaction fees:", fees_str)) detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
detaillayout.addWidget(qlabel("Status:", self.will[w].status)) detaillayout.addWidget(
qlabel("Status:", self.will[w].status + signature_suffix(self.will[w]))
)
detaillayout.addWidget(QLabel("")) detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>")) detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs: for heir in self.will[w].heirs:
@@ -1570,6 +1318,6 @@ class WillWidget(QWidget):
detailw.setPalette(pal) detailw.setPalette(pal)
hlayout.addWidget(detailw) hlayout.addWidget(detailw)
hlayout.addWidget(WillWidget(w, parent=parent)) hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))

View File

@@ -14,15 +14,28 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
it with the GUI. it with the GUI.
""" """
import json
import threading import threading
from electrum.util import MyEncoder
from ...core.checkalive import (
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
from .common import * from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *" from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget from .dialogs import (
from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog, BalBuildWillDialog,
BalWaitingDialog, BalWizardDialog, WillDetailDialog, BalDialog,
WillExecutorDialog) BalWaitingDialog,
from .lists import HeirListWidget, PreviewList, WillExecutorWidget BalWizardDialog,
WillDetailDialog,
WillExecutorDialog,
)
from .lists import HeirListWidget, PreviewList
from .widgets import LockTimeWidget, PercAmountEdit
class BalWindow: class BalWindow:
@@ -118,7 +131,22 @@ class BalWindow:
for k in keys: for k in keys:
del self.will[k] del self.will[k]
for wid, w in self.willitems.items(): for wid, w in self.willitems.items():
self.will[wid] = w.to_dict() d = w.to_dict()
# Store the tx in its serialized form: the wallet DB deep-copies
# every value on save (JsonDB.put), and a live Transaction carrying
# wallet-derived input info (utxo / script_descriptor, which hold a
# threading.RLock) cannot be deep-copied. Serializing to a string
# matches how the will is re-read (get_will -> tx_from_any).
d["tx"] = str(d["tx"])
# Mirror the encoder the wallet DB uses: JsonDB.put returns False
# (silently dropping the will) when a value cannot be serialized,
# so prove it here and fail loudly instead.
try:
json.dumps(d, cls=MyEncoder)
except Exception as e:
_logger.error(f"save_willitems: will {wid} is not serializable: {e!r}")
raise
self.will[wid] = d
def init_will(self): def init_will(self):
_logger.info("********************init_____will____________**********") _logger.info("********************init_____will____________**********")
@@ -200,15 +228,61 @@ class BalWindow:
heir_address.setFixedWidth(32 * char_width_in_lineedit()) heir_address.setFixedWidth(32 * char_width_in_lineedit())
heir_amount = PercAmountEdit(self.window.get_decimal_point) heir_amount = PercAmountEdit(self.window.get_decimal_point)
# OP_RETURN message field (hidden by default)
op_return_message = QLineEdit()
op_return_message.setFixedWidth(32 * char_width_in_lineedit())
op_return_message.setVisible(False)
op_return_amount_label = QLabel(_("Amount"))
op_return_message_label = QLabel(_("OP_RETURN Message"))
if heir: if heir:
heir_name.setText(str(heir_key)) heir_name.setText(str(heir_key))
heir_address.setText(str(heir[0])) addr = str(heir[0])
heir_address.setText(addr)
if not is_op_return_address(addr):
heir_amount.setText( heir_amount.setText(
str(Util.decode_amount(heir[1], self.window.get_decimal_point())) str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
) )
self.heir_locktime = LockTimeWidget(self, self.window, heir[2]) self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
else:
heir_address.setText("")
self.heir_locktime = LockTimeWidget(self, self.window, self.will_settings["locktime"])
# heir_is_xpub = QCheckBox() def _update_op_return_from_message():
msg = op_return_message.text()
data_hex = msg.encode("utf-8").hex()
heir_address.setText(OP_RETURN_PREFIX + data_hex)
def _update_op_return_from_address():
addr = heir_address.text()
if is_op_return_address(addr):
data_hex = addr[len(OP_RETURN_PREFIX):]
try:
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
op_return_message.setText(decoded)
except Exception:
op_return_message.setText("")
def _on_address_changed():
addr = heir_address.text()
if is_op_return_address(addr):
if not op_return_message.isVisible():
op_return_message.setVisible(True)
heir_amount.setVisible(False)
op_return_amount_label.setVisible(False)
op_return_message_label.setVisible(True)
op_return_message.blockSignals(True)
_update_op_return_from_address()
op_return_message.blockSignals(False)
else:
op_return_message.setVisible(False)
heir_amount.setVisible(True)
op_return_amount_label.setVisible(True)
op_return_message_label.setVisible(False)
_on_address_changed()
heir_address.textChanged.connect(_on_address_changed)
op_return_message.textChanged.connect(_update_op_return_from_message)
new_heir_button = QPushButton(_("Add another heir")) new_heir_button = QPushButton(_("Add another heir"))
self.add_another_heir = False self.add_another_heir = False
@@ -225,12 +299,15 @@ class BalWindow:
grid.addWidget(QLabel(_("Address")), 2, 0) grid.addWidget(QLabel(_("Address")), 2, 0)
grid.addWidget(heir_address, 2, 1) grid.addWidget(heir_address, 2, 1)
grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2) grid.addWidget(HelpButton(_("Bitcoin address or OP_RETURN: prefix + hex data")), 2, 2)
grid.addWidget(QLabel(_("Amount")), 3, 0) grid.addWidget(op_return_amount_label, 3, 0)
grid.addWidget(heir_amount, 3, 1) grid.addWidget(heir_amount, 3, 1)
grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2) grid.addWidget(HelpButton(_("Fixed or Percentage amount if end with %")), 3, 2)
grid.addWidget(op_return_message_label, 3, 0)
grid.addWidget(op_return_message, 3, 1)
locktime_label = QLabel(_("Locktime")) locktime_label = QLabel(_("Locktime"))
enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get() enable_multiverse = self.bal_plugin.ENABLE_MULTIVERSE.get()
if enable_multiverse: if enable_multiverse:
@@ -244,11 +321,15 @@ class BalWindow:
buttons.append(new_heir_button) buttons.append(new_heir_button)
vbox.addLayout(Buttons(*buttons)) vbox.addLayout(Buttons(*buttons))
while d.exec(): while d.exec():
# TODO SAVE HEIR raw_address = heir_address.text()
if is_op_return_address(raw_address):
amount = "0"
else:
amount = Util.encode_amount(heir_amount.text(), self.window.get_decimal_point())
heir = [ heir = [
heir_name.text(), heir_name.text(),
heir_address.text(), raw_address,
Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()), amount,
str(self.will_settings["locktime"]), str(self.will_settings["locktime"]),
] ]
try: try:
@@ -261,6 +342,8 @@ class BalWindow:
def set_heir(self, heir): def set_heir(self, heir):
heir = list(heir) heir = list(heir)
if is_op_return_address(heir[1]):
heir[2] = "0"
if not self.bal_plugin.ENABLE_MULTIVERSE.get(): if not self.bal_plugin.ENABLE_MULTIVERSE.get():
heir[3] = self.will_settings["locktime"] heir[3] = self.will_settings["locktime"]
@@ -295,6 +378,12 @@ class BalWindow:
will = self.build_inheritance_transaction( will = self.build_inheritance_transaction(
ignore_duplicate=ignore_duplicate, keep_original=keep_original ignore_duplicate=ignore_duplicate, keep_original=keep_original
) )
# Persist the freshly prepared transactions into the wallet's local
# history (when SAVE_HISTORY is enabled). This runs on every successful
# prepare -- including the "Prepare" menu action -- so the New txs show
# up in History immediately. Abort paths return None and are skipped.
if will:
self._save_will_to_history()
return will return will
def delete_not_valid(self, txid, s_utxo): def delete_not_valid(self, txid, s_utxo):
@@ -318,7 +407,12 @@ class BalWindow:
f = False f = False
for _u, w in self.willexecutors.items(): for _u, w in self.willexecutors.items():
if Willexecutors.is_selected(w): if Willexecutors.is_selected(
w
) and Willexecutors.is_valid(
w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.window.wallet.dust_threshold()
):
f = True f = True
if not f: if not f:
_logger.error("No Will-Executor or backup transaction selected") _logger.error("No Will-Executor or backup transaction selected")
@@ -328,12 +422,20 @@ class BalWindow:
# date_to_check already carries the correct reference timestamp for # date_to_check already carries the correct reference timestamp for
# the current mode (the Check Alive in ADVANCED, or "now" in BASIC - # the current mode (the Check Alive in ADVANCED, or "now" in BASIC -
# see init_class_variables). So build the will directly against it; # see init_class_variables). So build the will directly against it;
# no per-mode branch is needed here anymore. # no per-mode branch is needed here anymore. The available-UTXO view
# restores coins that a newer, wallet-local will tx (stored in the
# history with a later locktime) nominally spent.
txs = self.heirs.get_transactions( txs = self.heirs.get_transactions(
self.bal_plugin, self.bal_plugin,
self.window.wallet, self.window.wallet,
self.will_settings["baltx_fees"], self.will_settings["baltx_fees"],
None, Util.get_available_utxos(
self.window.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
),
self.date_to_check, self.date_to_check,
) )
@@ -369,23 +471,81 @@ class BalWindow:
return self.willitems return self.willitems
def check_will(self): def check_will(self):
return Will.is_will_valid( result = Will.is_will_valid(
self.willitems, self.willitems,
self.date_to_check, self.date_to_check,
self.will_settings["baltx_fees"], self.will_settings["baltx_fees"],
self.window.wallet.get_utxos(), Util.get_available_utxos(
self.window.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
),
heirs=self.heirs, heirs=self.heirs,
willexecutors=self.willexecutors, willexecutors=self.willexecutors,
self_willexecutor=self.no_willexecutor, self_willexecutor=self.no_willexecutor,
wallet=self.wallet, wallet=self.wallet,
callback_not_valid_tx=self.delete_not_valid, callback_not_valid_tx=self.delete_not_valid,
) )
return result
def _save_will_to_history(self):
"""Persist the current will state into the wallet's LOCAL history.
Runs after the will has been prepared/built/signed/checked (the
"Prepare" action, the check dialog's phase 2 and the manual Sign
action). When the SAVE_HISTORY setting is enabled,
``Will.save_valid_transactions_to_history`` stores the still "New" (not
fully-signed) transactions under the configured label and removes
entries for fully-signed ("Complete") and stale ones. The wallet tabs
are then re-rendered through ``_refresh_after_history_save``.
This must never raise: history persistence is a convenience on top of
the will flows, so any failure is logged and ignored.
"""
try:
if not bool(self.bal_plugin.SAVE_HISTORY.get()):
return
Will.save_valid_transactions_to_history(
self.willitems,
self.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
)
except Exception as e:
_logger.error(f"save_will_to_history failed: {e}")
self._schedule_history_refresh()
def _schedule_history_refresh(self):
"""Re-render the wallet tabs after the local history has changed.
The actual refresh must run on the GUI thread (``HistoryModel.refresh``
asserts that), so the call is marshalled through ``QTimer.singleShot``.
Used after saving/removing will transactions in the local history and
after a will rebuild, regardless of the calling thread.
"""
QTimer.singleShot(0, self._refresh_after_history_save)
def _refresh_after_history_save(self):
"""Re-render the wallet tabs after saving txs to the local history.
``update_tabs`` refreshes history plus the receive/send/address/coins
lists; ``update_status`` refreshes the status-bar balance, which
``update_tabs`` does not touch. When ``update_tabs`` is not available we
fall back to refreshing just the History tab.
"""
if hasattr(self.window, "update_tabs"):
self.window.update_tabs()
elif hasattr(self.window, "history_list"):
self.window.history_list.update()
if hasattr(self.window, "update_status"):
self.window.update_status()
def show_message(self, text): def show_message(self, text):
self.window.show_message(text) self.window.show_message(text)
def show_warning(self, text, parent=None): def show_warning(self, text, parent=None, title=None):
self.window.show_warning(text, parent=None) self.window.show_warning(text, parent=parent, title=title)
def show_error(self, text): def show_error(self, text):
self.window.show_error(text) self.window.show_error(text)
@@ -477,11 +637,13 @@ class BalWindow:
# against the current moment, i.e. the Check Alive effectively does # against the current moment, i.e. the Check Alive effectively does
# not exist, while the delivery time (locktime) is still fully # not exist, while the delivery time (locktime) is still fully
# enforced. ADVANCED mode keeps the user-controlled Check Alive # enforced. ADVANCED mode keeps the user-controlled Check Alive
# exactly as before. # exactly as before. The policy itself lives in
if self.bal_plugin.is_basic_mode(): # ``bal.core.checkalive.resolve_date_to_check``.
self.date_to_check = datetime.now().timestamp() self.date_to_check = resolve_date_to_check(
else: self.bal_plugin.is_basic_mode(),
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() self.will_settings,
built_locktime=Will.get_min_locktime(self.willitems),
)
# found = False # found = False
# NOTE: block-height tracking removed (A1) - locktimes are always # NOTE: block-height tracking removed (A1) - locktimes are always
# UNIX timestamps now, so we no longer read the current block height # UNIX timestamps now, so we no longer read the current block height
@@ -498,9 +660,10 @@ class BalWindow:
# flow. In BASIC we therefore SKIP this check entirely, so a passed # flow. In BASIC we therefore SKIP this check entirely, so a passed
# check-alive date never forces a postpone/rewrite of the will. The # check-alive date never forces a postpone/rewrite of the will. The
# delivery time (locktime) is unaffected and still fully enforced. # delivery time (locktime) is unaffected and still fully enforced.
if ( # The BASIC/ADVANCED rule lives in
not self.bal_plugin.is_basic_mode() # ``bal.core.checkalive.check_alive_expired``.
and self.date_to_check < datetime.now().timestamp() if check_alive_expired(
self.bal_plugin.is_basic_mode(), self.date_to_check
): ):
raise CheckAliveError(self.date_to_check) raise CheckAliveError(self.date_to_check)
@@ -529,9 +692,16 @@ class BalWindow:
Will.check_amounts( Will.check_amounts(
self.heirs, self.heirs,
self.willexecutors, self.willexecutors,
self.window.wallet.get_utxos(), Util.get_available_utxos(
self.window.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
),
self.date_to_check, self.date_to_check,
self.window.wallet.dust_threshold(), self.window.wallet.dust_threshold(),
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
) )
except AmountException as e: except AmountException as e:
self.show_warning( self.show_warning(
@@ -539,6 +709,11 @@ class BalWindow:
f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}" f"In the inheritance process, the entire wallet will always be fully emptied. Your settings require an adjustment of the amounts.{e}"
) )
) )
except WillExecutorFeeTooHighException as e:
self.show_error(
_(f"Will-executor fee too high: {e}")
)
return
except CheckAliveError: except CheckAliveError:
self.show_error( self.show_error(
_( _(
@@ -553,7 +728,12 @@ class BalWindow:
if not self.no_willexecutor: if not self.no_willexecutor:
f = False f = False
for _k, we in self.willexecutors.items(): for _k, we in self.willexecutors.items():
if Willexecutors.is_selected(we): if Willexecutors.is_selected(
we
) and Willexecutors.is_valid(
we, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.window.wallet.dust_threshold()
):
f = True f = True
if not f: if not f:
self.show_error( self.show_error(
@@ -664,8 +844,7 @@ class BalWindow:
) )
) )
self.window.history_list.update() self._schedule_history_refresh()
self.window.utxo_list.update()
# Guide the user: the inheritance was just (re)built and is now # Guide the user: the inheritance was just (re)built and is now
# in the "New" state, so it must be SIGNED and then BROADCAST # in the "New" state, so it must be SIGNED and then BROADCAST
@@ -735,7 +914,7 @@ class BalWindow:
raise Exception(_("no tx")) raise Exception(_("no tx"))
return self.show_transaction_real(tx, parent=parent) return self.show_transaction_real(tx, parent=parent)
def invalidate_will(self): def invalidate_will(self, will=None):
def on_success(result): def on_success(result):
if result: if result:
self.show_message( self.show_message(
@@ -750,18 +929,29 @@ class BalWindow:
self.show_message(_("No transactions to invalidate")) self.show_message(_("No transactions to invalidate"))
def on_failure(exec_info): def on_failure(exec_info):
log_error(exec_info, self.bal_window) log_error(exec_info, self)
willitems = will if will is not None else self.willitems
fee_per_byte = self.will_settings.get("baltx_fees", 1) fee_per_byte = self.will_settings.get("baltx_fees", 1)
task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte) task = partial(
Will.invalidate_will,
willitems,
self.wallet,
fee_per_byte,
history_label=self.bal_plugin.HISTORY_LABEL.get(),
will_locktime=Will.get_min_locktime(
willitems, default_value=self.date_to_check
),
)
msg = _("Calculating Transactions") msg = _("Calculating Transactions")
self.waiting_dialog = BalWaitingDialog( self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False self, msg, task, on_success, on_failure, exe=False
) )
self.waiting_dialog.exe() self.waiting_dialog.exe()
def sign_transactions(self, password): def sign_transactions(self, password, will=None, txids=None):
try: try:
willitems = will if will is not None else self.willitems
txs = {} txs = {}
signed = None signed = None
tosign = None tosign = None
@@ -772,9 +962,21 @@ class BalWindow:
msg = _(f"signed: {signed}\n") msg = _(f"signed: {signed}\n")
return msg + _(f"signing: {tosign}") return msg + _(f"signing: {tosign}")
for txid in Will.only_valid(self.willitems): if txids is not None:
wi = self.willitems[txid] targets = [
tx = copy.deepcopy(wi.tx) t for t in txids
if t in willitems and willitems[t].get_status("VALID")
]
else:
targets = Will.only_valid(willitems)
for txid in targets:
wi = willitems[txid]
# Do NOT deepcopy: the stored tx carries wallet-derived objects
# (utxo / script_descriptor) that hold a threading.RLock, and
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
# from the serialized form instead, which is exactly how the will
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
tx = Will.get_tx_from_any(str(wi.tx))
if wi.get_status("COMPLETE"): if wi.get_status("COMPLETE"):
txs[txid] = tx txs[txid] = tx
continue continue
@@ -785,8 +987,8 @@ class BalWindow:
pass pass
for txin in tx.inputs(): for txin in tx.inputs():
prevout = txin.prevout.to_json() prevout = txin.prevout.to_json()
if prevout[0] in self.willitems: if prevout[0] in willitems:
change = self.willitems[prevout[0]].tx.outputs()[prevout[1]] change = willitems[prevout[0]].tx.outputs()[prevout[1]]
txin._trusted_value_sats = change.value txin._trusted_value_sats = change.value
try: try:
txin.script_descriptor = change.script_descriptor txin.script_descriptor = change.script_descriptor
@@ -803,6 +1005,16 @@ class BalWindow:
if tx.is_complete(): if tx.is_complete():
# is_complete = True # is_complete = True
wi.set_status("COMPLETE", True) wi.set_status("COMPLETE", True)
# Refresh the per-item signature counts from the freshly signed
# partial tx: at this point the signatures are still present
# (before any finalization), so the will list can show the real
# "added/required" count (e.g. "1/2" for a multisig).
try:
have, required = tx.signature_count()
wi.sigs_have = int(have)
wi.sigs_required = int(required)
except Exception as e:
_logger.debug(f"signature_count after signing failed: {e}")
txs[txid] = tx txs[txid] = tx
except Exception: except Exception:
return None return None
@@ -869,16 +1081,32 @@ class BalWindow:
# re-wire them if this same window is reused for another wallet. # re-wire them if this same window is reused for another wallet.
self._menubar_initialized = False self._menubar_initialized = False
def ask_password_and_sign_transactions(self, callback=None): def ask_password_and_sign_transactions(self, callback=None, will=None, txids=None):
external = will is not None
willitems = will if external else self.willitems
def on_success(txs): def on_success(txs):
if txs: if txs:
for txid, tx in txs.items(): for txid, tx in txs.items():
self.willitems[txid].tx = copy.deepcopy(tx) # Re-parse instead of deepcopy: the signed tx may carry
self.will[txid] = self.willitems[txid].to_dict() # wallet-derived input info holding a threading.RLock, which
# copy.deepcopy cannot pickle (see sign_transactions above).
willitems[txid].tx = Will.get_tx_from_any(str(tx))
if not external:
self.will[txid] = willitems[txid].to_dict()
try:
Will.check_signatures(willitems, self.wallet)
except Exception as e:
_logger.error(f"check_signatures after signing failed: {e}")
if not external:
try: try:
self.will_list_widget.update() self.will_list_widget.update()
except Exception: except Exception:
pass pass
# After signing, keep the local history in sync (save the still
# incomplete "New" txs, remove the now-complete ones).
if not external:
self._save_will_to_history()
if callback: if callback:
try: try:
callback() callback()
@@ -886,18 +1114,21 @@ class BalWindow:
raise e raise e
def on_failure(exec_info): def on_failure(exec_info):
log_error(exec_info, self.bal_window) log_error(exec_info, self)
password = self.get_wallet_password() password = self.get_wallet_password()
task = partial(self.sign_transactions, password) task = partial(self.sign_transactions, password, will=will, txids=txids)
msg = _("Signing transactions...") msg = _("Signing transactions...")
self.waiting_dialog = BalWaitingDialog( self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False self, msg, task, on_success, on_failure, exe=False
) )
self.waiting_dialog.exe() self.waiting_dialog.exe()
def broadcast_transactions(self, force=False): def broadcast_transactions(self, force=False, will=None, txids=None):
external = will is not None
def on_success(sulcess): def on_success(sulcess):
if not external:
self.will_list_widget.update() self.will_list_widget.update()
if sulcess: if sulcess:
_logger.info("error, some transaction was not sent") _logger.info("error, some transaction was not sent")
@@ -909,7 +1140,7 @@ class BalWindow:
) )
def on_failure(exec_info): def on_failure(exec_info):
log_error(exec_info, self.bal_window) log_error(exec_info, self)
# a,b,c = err # a,b,c = err
# _logger.error(f"fail to broadcast transactions:{err}") # _logger.error(f"fail to broadcast transactions:{err}")
# _logger.error(f"error: {b}") # _logger.error(f"error: {b}")
@@ -923,15 +1154,20 @@ class BalWindow:
# _logger.error("lasti:", tb.tb_lasti) # _logger.error("lasti:", tb.tb_lasti)
# tb = tb.tb_next # tb = tb.tb_next
task = partial(self.push_transactions_to_willexecutors, force) task = partial(self.push_transactions_to_willexecutors, force, will=will, txids=txids)
msg = _("Selecting Will-Executors") msg = _("Selecting Will-Executors")
self.waiting_dialog = BalWaitingDialog( self.waiting_dialog = BalWaitingDialog(
self, msg, task, on_success, on_failure, exe=False self, msg, task, on_success, on_failure, exe=False
) )
self.waiting_dialog.exe() self.waiting_dialog.exe()
def push_transactions_to_willexecutors(self, force=False): def push_transactions_to_willexecutors(self, force=False, will=None, txids=None):
willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems, force=force) willitems = will if will is not None else self.willitems
if txids is not None:
willitems = {
t: willitems[t] for t in txids if t in willitems
}
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
def getMsg(willexecutors): def getMsg(willexecutors):
msg = "Broadcasting Transactions to Will-Executors:\n" msg = "Broadcasting Transactions to Will-Executors:\n"
@@ -960,11 +1196,11 @@ class BalWindow:
willexecutor["broadcast_status"] = _("checking...") willexecutor["broadcast_status"] = _("checking...")
elif ok: elif ok:
for wid in willexecutor.get("txsids", []): for wid in willexecutor.get("txsids", []):
self.willitems[wid].set_status("PUSHED", True) willitems[wid].set_status("PUSHED", True)
willexecutor["broadcast_status"] = _("Success") willexecutor["broadcast_status"] = _("Success")
else: else:
for wid in willexecutor.get("txsids", []): for wid in willexecutor.get("txsids", []):
self.willitems[wid].set_status("PUSH_FAIL", True) willitems[wid].set_status("PUSH_FAIL", True)
error["flag"] = True error["flag"] = True
willexecutor["broadcast_status"] = _("Failed") willexecutor["broadcast_status"] = _("Failed")
willexecutor.pop("txs", None) willexecutor.pop("txs", None)
@@ -989,54 +1225,191 @@ class BalWindow:
return return
self.waiting_dialog.update( self.waiting_dialog.update(
"checking {} - {} : {}".format( "checking {} - {} : {}".format(
self.willitems[wid].we["url"], wid, "Waiting" willitems[wid].we["url"], wid, "Waiting"
) )
) )
w = self.willitems[wid] w = willitems[wid]
w.set_check_willexecutor( w.set_check_willexecutor(
Willexecutors.check_transaction(wid, w.we["url"]) Willexecutors.check_transaction(wid, w.we["url"])
) )
self.waiting_dialog.update( self.waiting_dialog.update(
"checked {} - {} : {}".format( "checked {} - {} : {}".format(
self.willitems[wid].we["url"], willitems[wid].we["url"],
wid, wid,
self.willitems[wid].get_status("CHECKED"), willitems[wid].get_status("CHECKED"),
) )
) )
if error["flag"]: if error["flag"]:
return True return True
def export_json_file(self, path): def export_json_file(self, path, will=None):
if will is None:
for wid in self.willitems: for wid in self.willitems:
self.willitems[wid].set_status("EXPORTED", True) self.willitems[wid].set_status("EXPORTED", True)
self.will[wid] = self.willitems[wid].to_dict() self.will[wid] = self.willitems[wid].to_dict()
write_json_file(path, self.will) write_json_file(path, self.will)
else:
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
def export_will(self): def export_will(self, will=None):
try: try:
export_meta_gui(self.window, "will.json", self.export_json_file) export_meta_gui(
self.window, "will.json", partial(self.export_json_file, will=will)
)
except Exception as e: except Exception as e:
self.show_error(str(e)) self.show_error(str(e))
raise e raise e
def import_will(self): def merge_will(self, imported):
def sulcess(): """Merge imported will items into the live will.
Both the tools-menu "Merge" action and the details-dialog "Merge"
button go through this single method.
For a transaction that already exists in the live will the live
WillItem is kept (never replaced): only the operational statuses
(signed/pushed/checked/mempool/confirmed) that are True in the
imported item are carried over. When the live transaction is not
yet signed the imported transaction is merged into it (signatures
are combined when both are the same unsigned tx, otherwise the
transaction is substituted); an already-signed live transaction is
left untouched. Transactions that are new are added wholesale.
After the merge a local validity check recomputes the
valid/invalidated/replaced statuses (no server contact, no expiry
raise).
"""
# The reference timestamp is normally set by init_class_variables(),
# which the merge flow does not run (Merge -> file import can be the
# very first action in a session). Fall back to "now" so the local
# validity check and the trailing update_all() always have it.
if not hasattr(self, "date_to_check") or self.date_to_check is None:
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
)
for wid, wi in imported.items():
if wid in self.willitems:
live = self.willitems[wid]
was_complete = live.get_status("COMPLETE")
for status in (
"COMPLETE",
"PUSHED",
"CHECKED",
"MEMPOOL",
"CONFIRMED",
):
if wi.get_status(status):
live.set_status(status, True)
if not was_complete:
try:
if live.tx.txid() == wi.tx.txid():
live.tx.combine_with_other_psbt(wi.tx)
else:
live.tx = wi.tx
except Exception:
live.tx = wi.tx
if live.tx.is_complete():
live.set_status("COMPLETE", True)
else:
self.willitems[wid] = wi
Will.normalize_will(self.willitems, self.wallet)
self.save_willitems()
# Local validity check: recompute valid/invalidated/replaced statuses.
try:
Will.add_willtree(self.willitems)
bal_plugin = getattr(self, "bal_plugin", None)
history_label = (
bal_plugin.HISTORY_LABEL.get() if bal_plugin is not None else None
)
all_utxos = Util.get_available_utxos(
self.wallet,
history_label,
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
)
Will.check_invalidated(
self.willitems, Will.utxos_strs(all_utxos), self.wallet
)
Will.search_rai(
Will.get_all_inputs(self.willitems, only_valid=True),
all_utxos,
self.willitems,
self.wallet,
)
Will.check_signatures(self.willitems, self.wallet)
except Exception as e:
log_error(e, self)
self.save_willitems()
self.update_all()
def merge_will_from_file(self, path):
try:
willitems = self._load_will_file(path)
except Exception as e:
raise FileImportFailed(_("Invalid will file: {}").format(e)) from None
Will.normalize_will(willitems, self.wallet)
self.merge_will(willitems)
def merge_single_transaction(self, tx):
"""Merge a single raw transaction (e.g. from the clipboard or a file)
into the live will.
The transaction is wrapped in a fresh :class:`WillItem` and merged
through :meth:`merge_will`, so existing items are combined/updated and
new transactions are added wholesale, exactly like a will-file merge.
"""
wi = WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=self.wallet)
self.merge_will({wi._id: wi})
def merge_will_ui(self):
def on_success():
self.will_list_widget.update_will(self.willitems) self.will_list_widget.update_will(self.willitems)
import_meta_gui(self.window, _("will"), self.import_json_file, sulcess) import_meta_gui(self.window, _("will"), self.merge_will_from_file, on_success)
def import_json_file(self, path): def import_will_into_details(self):
"""Import a will file and show it in a WillDetails window.
Unlike the "Merge" actions (which merge the file into the active
will), this is a read-only preview: the parsed will is shown in a
:class:`WillDetailDialog` and the live wallet state is never touched.
The dialog's Sign/Broadcast/Export/Invalidate buttons operate on the
imported will only, and its Merge button merges the imported will
into the live one.
"""
imported = {}
def on_file(path):
try: try:
willitems = self._load_will_file(path)
except Exception as e:
self.show_error(_("Invalid will file: {}").format(e))
return
# Attach wallet/input info so the imported txs can be signed and
# broadcast (mirrors what merge_will_from_file does).
Will.normalize_will(willitems, self.wallet)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
imported.update(willitems)
def on_success():
if not imported:
return
d = WillDetailDialog(self, will=imported)
show_on_top(d)
import_meta_gui(self.window, _("will"), on_file, on_success)
def _load_will_file(self, path):
data = read_json_file(path) data = read_json_file(path)
willitems = {} willitems = {}
for k, v in data.items(): for k, v in data.items():
data[k]["tx"] = tx_from_any(v["tx"]) data[k]["tx"] = tx_from_any(v["tx"])
willitems[k] = WillItem(data[k], _id=k) willitems[k] = WillItem(data[k], _id=k)
self.update_will(willitems) return willitems
except Exception as e:
raise e
# raise FileImportFailed(_("Invalid will file"))
def check_transactions_task(self, will): def check_transactions_task(self, will):
start = time.time() start = time.time()
@@ -1396,14 +1769,15 @@ class BalWindow:
Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick) Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick)
def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None): def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None):
if not fn_on_failure:
fn_on_failure = log_error
def on_success(result): def on_success(result):
fn_on_success(result) fn_on_success(result)
def on_failure(exec_info): def on_failure(exec_info):
fn_on_failure(exec_info) fn_on_failure(exec_info)
if not fn_on_failure:
fn_on_failure = log_error
_logger.info("ping willexecutors") _logger.info("ping willexecutors")
task = partial(self.ping_willexecutors_task, wes) task = partial(self.ping_willexecutors_task, wes)
msg = _("Ping Will-Executors") msg = _("Ping Will-Executors")
@@ -1431,7 +1805,13 @@ class BalWindow:
for _wid, _w in list(self.willitems.items())[:3]: for _wid, _w in list(self.willitems.items())[:3]:
_logger.debug(f"NoneType_debug willitems[{_wid}] type={type(_w).__name__}") _logger.debug(f"NoneType_debug willitems[{_wid}] type={type(_w).__name__}")
Will.add_willtree(self.willitems) Will.add_willtree(self.willitems)
all_utxos = self.wallet.get_utxos() all_utxos = Util.get_available_utxos(
self.wallet,
self.bal_plugin.HISTORY_LABEL.get(),
Will.get_min_locktime(
self.willitems, default_value=self.date_to_check
),
)
utxos_list = Will.utxos_strs(all_utxos) utxos_list = Will.utxos_strs(all_utxos)
Will.check_invalidated(self.willitems, utxos_list, self.wallet) Will.check_invalidated(self.willitems, utxos_list, self.wallet)

View File

@@ -1,10 +1,12 @@
{ {
"name": "bal", "name": "bal",
"fullname": "Bitcoin After Life", "fullname": "Bitcoin After Life",
"version": "0.5.18", "version": "0.6.1",
"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",
"available_for": ["qt"], "available_for": [
"qt"
],
"icon": "icons/bal32x32.png" "icon": "icons/bal32x32.png"
} }

View File

@@ -6,11 +6,12 @@
Documentation for the **BAL** opensource Electrum plugin for Bitcoin digital Documentation for the **BAL** opensource Electrum plugin for Bitcoin digital
inheritance. Everything here is plain Markdown + images (and optional styled inheritance. Everything here is plain Markdown + images (and optional styled
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**. HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
**no PDF needed**.
## Contents ## Contents
| Document | Markdown (GitHub) | Styled HTML | | Document | Markdown | Styled HTML |
|---|---|---| |---|---|---|
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) | | **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) | | **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
@@ -23,9 +24,8 @@ HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed
## Viewing the HTML versions ## Viewing the HTML versions
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy - Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
from branch, folder `/docs`), then open GitHub) and open `manual/manual.html`.
`https://<owner>.github.io/<repo>/manual/manual.html`.
- Offline: download the `docs/` folder and open the `.html` files in any browser - Offline: download the `docs/` folder and open the `.html` files in any browser
(the styled manual works fully offline; the inheritanceoptions page loads (the styled manual works fully offline; the inheritanceoptions page loads
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback). Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).

View File

@@ -43,6 +43,7 @@ important ones:
|---|---|---| |---|---|---|
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL | | `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** |
| `PARTIALLY_SIGNED` | Only **some** of the required signatures are present | a multisig will after a partial sign (cleared by `COMPLETE`) |
| `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors | | `PUSHED` | The signed tx was **sent to the willexecutor(s)** | after **Broadcast** to executors |
| `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) | | `CHECKED` | The willexecutor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx | | `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
@@ -65,6 +66,7 @@ Flag transitions enforced by `set_status` (the safety rules baked in the code):
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`. - 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`).
- Setting `COMPLETE` → clears `PARTIALLY_SIGNED`.
### How states map to row colour in the list ### How states map to row colour in the list
@@ -83,7 +85,8 @@ wins:
| 7 | `CHECKED` | green | `#8afa6c` | | 7 | `CHECKED` | green | `#8afa6c` |
| 8 | `PUSH_FAIL` | red | `#e83845` | | 8 | `PUSH_FAIL` | red | `#e83845` |
| 9 | `PUSHED` | teal | `#73f3c8` | | 9 | `PUSHED` | teal | `#73f3c8` |
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` | | 10 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
| 11 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` | | — | 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*
@@ -354,5 +357,5 @@ that limit.
--- ---
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from *This document reflects the current BAL plugin (v0.6.1). Behaviour is derived
`core/will.py`, `core/heirs.py` and `gui/qt/window.py`.* directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*

View File

@@ -76,6 +76,22 @@ inheritance cases.
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive, *Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
(3) Fees.* (3) Fees.*
### User type: BASIC / ADVANCED
The plugin has two usage modes, chosen from the plugin settings
(**Tools → Plugins → BAL**, *User Type* selector):
- **BASIC** (default) — hides the advanced controls: the **Delivery Time** is
entered only as a precise **Date** (the relative **RAW** durations and the
Raw/Date selector are hidden), the **Check Alive** field is hidden, and the
postpone-on-open behaviour is disabled.
- **ADVANCED** — reveals the **Raw/Date selector** (relative durations such as
`1y` or `30d`) and the **Check Alive** field, and enables the postpone
behaviour described below. Switching to ADVANCED requires typing the
confirmation phrase **"at My Risk"**.
The rest of this section describes the full (ADVANCED) parameter set.
### 1 — Delivery Time (Locktime) ### 1 — Delivery Time (Locktime)
Indicates the date on which the inheritance of your wallet on the blockchain Indicates the date on which the inheritance of your wallet on the blockchain
@@ -94,6 +110,11 @@ If you choose **Raw**, you can insert various options based on a suffix:
*(i.e. check whether you are still alive, and then postpone the inheritance.)* *(i.e. check whether you are still alive, and then postpone the inheritance.)*
> **NB:** the **Check Alive** parameter is available only in **ADVANCED** mode.
> In **BASIC** (default) it is hidden and the plugin re-evaluates the will
> against "now" every time you open Electrum, so the postpone behaviour
> described here does not apply.
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
the time by which the inheritance will **not** be changed by postponing it. the time by which the inheritance will **not** be changed by postponing it.
@@ -223,6 +244,9 @@ plugin will notify you that you need to update the inheritance.
## RAW settings ## RAW settings
> **NB:** relative (**RAW**) durations are available only in **ADVANCED** mode;
> in **BASIC** the Delivery Time is entered only as a precise date.
If you set, for example, `RAW1d` and it is, say, 5 p.m., the plugin will not If you set, for example, `RAW1d` and it is, say, 5 p.m., the plugin will not
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
roughly estimate the blockchain block number corresponding to that time — so roughly estimate the blockchain block number corresponding to that time — so
@@ -239,7 +263,8 @@ with a tolerance of a few hours.
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
later). For such short intervals the **Check Alive** could create problems, so later). For such short intervals the **Check Alive** could create problems, so
set the Check Alive parameter **in the past** (a date before today) — e.g. a set the Check Alive parameter **in the past** (a date before today) — e.g. a
previous month. previous month. *(The Check Alive only exists in **ADVANCED** mode; in
**BASIC** this is not needed.)*
--- ---
@@ -335,7 +360,7 @@ inheritance:
## WillExecutor service list ## WillExecutor service list
This window opens from the Electrum menu, **Tools → Willexecutor**, and shows This window opens from the Electrum menu, **Tools → WillExecutors**, and shows
the official list of willexecutor servers. the official list of willexecutor servers.
If you want to make changes — such as adding an additional willexecutor server — If you want to make changes — such as adding an additional willexecutor server —
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each willexecutor that is online.
| # | Status | Meaning | Colour | HEX | | # | Status | Meaning | Colour | HEX |
|---|--------|---------|--------|-----| |---|--------|---------|--------|-----|
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` | | 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` | | 2 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
| 3 | **Pushed** | TX sent to willexecutor | Azuregreen | `#73F3C8` | | 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
| 4 | **Checked** | TX actually present in the willexecutor | Bright green | `#8AFA6C` | | 4 | **Pushed** | TX sent to willexecutor | Azuregreen | `#73F3C8` |
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` | | 5 | **Checked** | TX actually present in the willexecutor | Bright green | `#8AFA6C` |
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` | | 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
| 7 | **Failed** | Communication failure with willexecutor | Red | `#E83845` | | 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` | | 8 | **Failed** | Communication failure with willexecutor | Red | `#E83845` |
| 9 | **Replaced** | A backdatedlocktime transaction spends the same input | Violet | `#FF97E9` | | 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
| 10 | **Replaced** | A backdatedlocktime transaction spends the same input | Violet | `#FF97E9` |
| 11 | **Updated** | TX reissued keeping the same locktime and heirs | Light violet | `#B266B2` |
--- ---

285
make-release.sh Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# make-release.sh — Create a Gitea release for bal-electrum-plugin
#
# Usage:
# ./make-release.sh # read version from bal/manifest.json
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
#
# Requires: git, gpg, curl, python3, sha256sum
# Optional: ruff (lint skipped if not installed)
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
set -eo pipefail
# ── helpers ──────────────────────────────────────────────────────────
die() { echo "Error: $*" >&2; exit 1; }
info() { echo ""; echo "── $* ──"; }
# ── 0. Resolve version ──────────────────────────────────────────────
MANIFEST="bal/manifest.json"
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
# read current version from manifest
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
ARG="${1:-}"
if [ -n "$ARG" ]; then
# normalise: accept "v0.6.2" or "0.6.2"
NEW_VER="${ARG#v}"
TAG="v${NEW_VER}"
else
TAG="v${CURRENT_VER}"
NEW_VER=""
fi
echo "=== Release ${TAG} ==="
echo "Current manifest version: ${CURRENT_VER}"
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
# ── 1. Bump version in manifest (if arg provided) ──────────────────
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
python3 -c "
import json
f = open('${MANIFEST}')
d = json.load(f); f.close()
d['version'] = '${NEW_VER}'
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
print(json.dumps(d, indent=4, ensure_ascii=False))
"
git add "$MANIFEST"
else
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
fi
# ── 2. Clean caches ─────────────────────────────────────────────────
info "[2/10] Cleaning __pycache__ and .pyc"
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find bal -name "*.pyc" -delete 2>/dev/null || true
find bal -name "*.pyo" -delete 2>/dev/null || true
# ── 3. Run tests ────────────────────────────────────────────────────
info "[3/10] Running test suite"
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
tests/test_core_*.py \
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
tests/test_group_h_v048.py \
-q 2>&1; then
echo "All tests passed."
else
die "Tests failed — aborting release."
fi
# ── 4. Lint (optional) ─────────────────────────────────────────────
info "[4/10] Lint with ruff"
if command -v ruff &>/dev/null; then
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
| grep -vE "F401|F403|F405|F841" || true)
if [ -n "$RUFF_ERRORS" ]; then
echo "New ruff errors:"
echo "$RUFF_ERRORS"
die "Lint errors found — fix before releasing."
else
echo "Lint clean (ignoring known pre-existing warnings)."
fi
else
echo "ruff not installed — skipping lint."
fi
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
info "[5/10] Building ZIP"
ZIP_NAME="bal_${TAG}.zip"
python3 build_zip.py "$ZIP_NAME"
# ── 6. GPG sign (armor + binary) + export public key ───────────────
info "[6/10] Signing with GPG"
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
ASC_FILE="${ZIP_NAME}.asc"
SIG_FILE="${ZIP_NAME}.sig"
PGP_FILE="svatantrya.asc"
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
echo " Signed: $ASC_FILE"
echo " Signed: $SIG_FILE"
echo " Public key: $PGP_FILE"
# ── 7. SHA-256 checksum ────────────────────────────────────────────
info "[7/10] Computing SHA-256"
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
echo " SHA-256: ${SHA256_HASH}"
# ── 8. Pause for Electrum test ─────────────────────────────────────
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
echo ""
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
echo ""
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
echo ""
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
case "$CONFIRM" in
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
*) die "Aborted by user." ;;
esac
# ── 9. Git tag + push ──────────────────────────────────────────────
info "[9/10] Creating and pushing tag ${TAG}"
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
# credentials
GITEA_USER="${GITEA_USER:-}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
if [ -f ~/.git-credentials ]; then
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
if [ -n "$CREDS_LINE" ]; then
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
GITEA_TOKEN="$GITEA_PASS"
fi
fi
fi
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
API="https://$GITEA_HOST/gitea/api/v1"
# create annotated tag
git tag -d "$TAG" 2>/dev/null || true
git tag -a "$TAG" -m "$TAG" HEAD
# push tag
REMOTE_NAME="gitea-target"
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
git remote rm "$REMOTE_NAME" 2>/dev/null || true
git remote add "$REMOTE_NAME" "$REMOTE_URL"
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
git push "$REMOTE_NAME" "$TAG" --force
# ── 10. Create release + upload assets ──────────────────────────────
info "[10/10] Creating Gitea release"
RELEASE_BODY=$(python3 -c "
import json
sha256 = '${SHA256_HASH}'
zip_name = '${ZIP_NAME}'
asc_name = '${ASC_FILE}'
sig_name = '${SIG_FILE}'
pgp_file = '${PGP_FILE}'
tag = '${TAG}'
body = f'''Release {tag}
## SHA-256 Checksum
\`\`\`
{sha256} {zip_name}
\`\`\`
### Verify SHA-256
\`\`\`bash
sha256sum -c {zip_name}.sha256
\`\`\`
## Download
- \`{zip_name}\` - Plugin BAL {tag}
- \`{asc_name}\` - GPG signature (armor)
- \`{sig_name}\` - GPG signature (binary)
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
## GPG Verification
### Import the signing key
\`\`\`bash
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
\`\`\`
Or download \`{pgp_file}\` from the assets above:
\`\`\`bash
gpg --import {pgp_file}
\`\`\`
### Verify the signature (armor)
\`\`\`bash
gpg --verify {asc_name} {zip_name}
\`\`\`
### Verify the signature (binary)
\`\`\`bash
gpg --verify {sig_name} {zip_name}
\`\`\`
Expected output:
\`\`\`
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
\`\`\`
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
Public key: https://bitcoin-after.life/svatantrya.asc'''
print(json.dumps({'body': body}, ensure_ascii=False))
")
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "Error creating release: HTTP $HTTP_CODE"
echo "$BODY"
exit 1
fi
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
# upload assets
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
BASENAME=$(basename "$FILE")
echo " Uploading $BASENAME ..."
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
-F "attachment=@${FILE}" -F "name=${BASENAME}")
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
if [ "$UPLOAD_CODE" == "201" ]; then
echo " OK ($BASENAME)"
else
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
fi
done
echo ""
echo "=== Done ==="
echo "Release: $HTML_URL"
echo "Assets:"
echo " ${ZIP_NAME}"
echo " ${ASC_FILE}"
echo " ${SIG_FILE}"
echo " ${ZIP_NAME}.sha256"
echo " ${PGP_FILE}"
echo "SHA-256: ${SHA256_HASH}"

19
pyproject.toml Normal file
View File

@@ -0,0 +1,19 @@
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
select =["E", "W", "F", "I", "N", "B"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"bal/gui/qt/*.py" = ["F403", "F405"] # intentional `from .common import *` hub
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via `import *`
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
"bal/gui/qt/window.py" = ["N802"] # getMsg
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
"bal/core/will.py" = ["N818"] # public exception names
"bal/core/willexecutors.py" = ["N818"] # public exception names
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import

30
svatantrya.asc Normal file
View File

@@ -0,0 +1,30 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
=6EE4
-----END PGP PUBLIC KEY BLOCK-----

View File

@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
if isinstance(node.value, str) and hasattr(node, "end_lineno"): if isinstance(node.value, str) and hasattr(node, "end_lineno"):
self.spans.append((node.lineno, node.end_lineno)) self.spans.append((node.lineno, node.end_lineno))
self.generic_visit(node) self.generic_visit(node)
s = _S(); s.visit(tree) s = _S()
s.visit(tree)
drop = set() drop = set()
for a, b in s.spans: for a, b in s.spans:
drop.update(range(a, b + 1)) drop.update(range(a, b + 1))
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
def main(pkg: str) -> int: def main(pkg: str) -> int:
from PyQt6.QtWidgets import QApplication, QDialog, QWidget from PyQt6.QtWidgets import QApplication, QDialog, QWidget
app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
wu = importlib.import_module(pkg + ".gui.qt.window_utils") wu = importlib.import_module(pkg + ".gui.qt.window_utils")
# top_level_of: returns the top-level container of a child widget # top_level_of: returns the top-level container of a child widget
w = QWidget(); child = QWidget(w) w = QWidget()
child = QWidget(w)
assert wu.top_level_of(child) is w assert wu.top_level_of(child) is w
assert wu.top_level_of(None) is None assert wu.top_level_of(None) is None
print("[OK] top_level_of") print("[OK] top_level_of")

File diff suppressed because one or more lines are too long

View File

@@ -31,7 +31,7 @@ N = 8 # number of servers
def main(): def main():
we_mod = importlib.import_module(f"{PKG}.core.willexecutors") we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
W = we_mod.Willexecutors we_cls = we_mod.Willexecutors
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ---- # ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
def slow_get_info(url, we, **kwargs): def slow_get_info(url, we, **kwargs):
@@ -43,8 +43,8 @@ def main():
we["status"] = 200 we["status"] = 200
return we return we
orig_get_info = W.get_info_task orig_get_info = we_cls.get_info_task
W.get_info_task = staticmethod(slow_get_info) we_cls.get_info_task = staticmethod(slow_get_info)
try: try:
wes = {} wes = {}
for i in range(N): for i in range(N):
@@ -57,7 +57,7 @@ def main():
seen.append((url, ok)) seen.append((url, ok))
start = time.time() start = time.time()
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N) we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
elapsed = time.time() - start elapsed = time.time() - start
# Sequential would take ~ N * SLOW. Parallel must be far less. # Sequential would take ~ N * SLOW. Parallel must be far less.
@@ -81,15 +81,15 @@ def main():
assert we["status"] == "KO", (url, we) assert we["status"] == "KO", (url, we)
print("[OK] ping results written back into the willexecutors mapping") print("[OK] ping results written back into the willexecutors mapping")
finally: finally:
W.get_info_task = orig_get_info we_cls.get_info_task = orig_get_info
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ---- # ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
def slow_push(we, **kwargs): def slow_push(we, **kwargs):
time.sleep(SLOW) time.sleep(SLOW)
return "fail" not in we["url"] return "fail" not in we["url"]
orig_push = W.push_transactions_to_willexecutor orig_push = we_cls.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push) we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
try: try:
wes = {} wes = {}
for i in range(N): for i in range(N):
@@ -106,7 +106,7 @@ def main():
pushed.append((url, ok)) pushed.append((url, ok))
start = time.time() start = time.time()
results = W.push_transactions_parallel(wes, on_each=on_each_push, results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
max_workers=N) max_workers=N)
elapsed = time.time() - start elapsed = time.time() - start
@@ -117,11 +117,11 @@ def main():
f"(sequential would be ~{sequential:.2f}s)") f"(sequential would be ~{sequential:.2f}s)")
assert len(results) == N, results assert len(results) == N, results
for url, (ok, exc) in results.items(): for url, (ok, _exc) in results.items():
assert ok == ("good" in url), (url, ok) assert ok == ("good" in url), (url, ok)
print("[OK] push results correct for every server") print("[OK] push results correct for every server")
finally: finally:
W.push_transactions_to_willexecutor = orig_push we_cls.push_transactions_to_willexecutor = orig_push
# ---- 2b) global deadline: a hung server must not block past `deadline` ---- # ---- 2b) global deadline: a hung server must not block past `deadline` ----
def hanging_push(we, **kwargs): def hanging_push(we, **kwargs):
@@ -129,8 +129,8 @@ def main():
time.sleep(10) time.sleep(10)
return True return True
orig_push2 = W.push_transactions_to_willexecutor orig_push2 = we_cls.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(hanging_push) we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
try: try:
wes = { wes = {
"https://fast.example": { "https://fast.example": {
@@ -146,7 +146,7 @@ def main():
return True return True
time.sleep(10) time.sleep(10)
return True return True
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang) we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
timed_out = [] timed_out = []
@@ -154,7 +154,7 @@ def main():
timed_out.append(url) timed_out.append(url)
start = time.time() start = time.time()
W.push_transactions_parallel( we_cls.push_transactions_parallel(
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
) )
elapsed = time.time() - start elapsed = time.time() - start
@@ -163,7 +163,7 @@ def main():
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, " print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
f"hung server reported via on_timeout") f"hung server reported via on_timeout")
finally: finally:
W.push_transactions_to_willexecutor = orig_push2 we_cls.push_transactions_to_willexecutor = orig_push2
# ---- 2c) on_tick is fired periodically from the CALLING thread ---- # ---- 2c) on_tick is fired periodically from the CALLING thread ----
# The elapsed-time counter is driven by an on_tick callback called from the # The elapsed-time counter is driven by an on_tick callback called from the
@@ -175,8 +175,8 @@ def main():
time.sleep(SLOW * 6) # ~3s, long enough for several ticks time.sleep(SLOW * 6) # ~3s, long enough for several ticks
return True return True
orig_push3 = W.push_transactions_to_willexecutor orig_push3 = we_cls.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push2) we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
try: try:
wes = { wes = {
"https://tick.example": { "https://tick.example": {
@@ -191,7 +191,7 @@ def main():
ticks.append(time.time()) ticks.append(time.time())
tick_threads.add(threading.current_thread()) tick_threads.add(threading.current_thread())
W.push_transactions_parallel( we_cls.push_transactions_parallel(
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5 wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
) )
# ~3s push with 0.5s ticks => at least a few ticks. # ~3s push with 0.5s ticks => at least a few ticks.
@@ -202,7 +202,7 @@ def main():
) )
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread") print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
finally: finally:
W.push_transactions_to_willexecutor = orig_push3 we_cls.push_transactions_to_willexecutor = orig_push3
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ---- # ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
# Pressing "Check" verifies each will-executor still holds its tx. This used # Pressing "Check" verifies each will-executor still holds its tx. This used
@@ -214,8 +214,8 @@ def main():
time.sleep(SLOW) time.sleep(SLOW)
return {"tx": "ok"} if "good" in url else None return {"tx": "ok"} if "good" in url else None
orig_check = W.check_transaction orig_check = we_cls.check_transaction
W.check_transaction = staticmethod(slow_check) we_cls.check_transaction = staticmethod(slow_check)
try: try:
targets = [] targets = []
for i in range(N): for i in range(N):
@@ -228,7 +228,7 @@ def main():
checked.append((wid, res)) checked.append((wid, res))
start = time.time() start = time.time()
results = W.check_transactions_parallel( results = we_cls.check_transactions_parallel(
targets, on_each=on_each_check, max_workers=N targets, on_each=on_each_check, max_workers=N
) )
elapsed = time.time() - start elapsed = time.time() - start
@@ -239,7 +239,7 @@ def main():
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers " print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)") f"(sequential would be ~{sequential:.2f}s)")
finally: finally:
W.check_transaction = orig_check we_cls.check_transaction = orig_check
# 2d-bis) global deadline + on_tick from the calling thread # 2d-bis) global deadline + on_tick from the calling thread
def hanging_check(txid, url, **kwargs): def hanging_check(txid, url, **kwargs):
@@ -248,8 +248,8 @@ def main():
time.sleep(10) time.sleep(10)
return {"tx": "ok"} return {"tx": "ok"}
orig_check2 = W.check_transaction orig_check2 = we_cls.check_transaction
W.check_transaction = staticmethod(hanging_check) we_cls.check_transaction = staticmethod(hanging_check)
try: try:
targets = [ targets = [
("idf", "https://fast.example"), ("idf", "https://fast.example"),
@@ -268,7 +268,7 @@ def main():
tick_threads.add(threading.current_thread()) tick_threads.add(threading.current_thread())
start = time.time() start = time.time()
W.check_transactions_parallel( we_cls.check_transactions_parallel(
targets, max_workers=2, deadline=2.0, targets, max_workers=2, deadline=2.0,
on_timeout=on_timeout_check, on_tick=on_tick_check, on_timeout=on_timeout_check, on_tick=on_tick_check,
tick_interval=0.5, tick_interval=0.5,
@@ -282,7 +282,7 @@ def main():
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick " print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
f"fired {len(ticks)}x from the calling thread") f"fired {len(ticks)}x from the calling thread")
finally: finally:
W.check_transaction = orig_check2 we_cls.check_transaction = orig_check2
# ---- 3) the wizard's loop_push must use the parallel helper ---- # ---- 3) the wizard's loop_push must use the parallel helper ----
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push. # The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.

View File

@@ -17,8 +17,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
# Same colors as BalBuildWillDialog # Same colors as BalBuildWillDialog
COLOR_WARNING = "#cfa808" COLOR_WARNING = "#cfa808"

View File

@@ -17,10 +17,15 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import Qt # noqa: E402 from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
COLOR_OK = "#05ad05" COLOR_OK = "#05ad05"

View File

@@ -21,8 +21,8 @@ import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
COLOR_ERROR = "#ff0000" COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05" COLOR_OK = "#05ad05"

View File

@@ -20,12 +20,17 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402 from PyQt6.QtCore import QSize # noqa: E402
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
QLabel,
)
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402 from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
from PyQt6.QtCore import QSize, Qt # noqa: E402 from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
)
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons", ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
"wizard.png") "wizard.png")

View File

@@ -27,12 +27,19 @@ import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
QLineEdit, QSpinBox, QLabel,
)
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtCore import Qt # noqa: E402 from PyQt6.QtCore import Qt # noqa: E402
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
def _char_w(): def _char_w():

792
tests/samanta7 Normal file

File diff suppressed because one or more lines are too long

View File

@@ -21,18 +21,21 @@ Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
""" """
import sys
import os
import copy import copy
import os
import sys
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))
from bal.core.will import ( from bal.core.will import (
WillItem, Will, HeirNotFoundException,
NotCompleteWillException, HeirNotFoundException, NoHeirsException, NoHeirsException,
TxFeesChangedException, WillExpiredException, NotCompleteWillException,
TxFeesChangedException,
Will,
WillExpiredException,
WillItem,
) )
from bal.core.util import Util
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0. # A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
_VALID_TX_HEX = ( _VALID_TX_HEX = (

View File

@@ -26,30 +26,28 @@ def main():
from PyQt6.QtWidgets import QApplication # noqa from PyQt6.QtWidgets import QApplication # noqa
_app = QApplication.instance() or QApplication([]) _app = QApplication.instance() or QApplication([])
results = {}
# 1) Core modules import (these must be GUI-free). # 1) Core modules import (these must be GUI-free).
bal = imp_core("bal", "core.plugin_base") bal = imp_core("bal", "core.plugin_base")
util = imp_core("util", "core.util") util = imp_core("util", "core.util")
heirs = imp_core("heirs", "core.heirs") heirs = imp_core("heirs", "core.heirs")
will = imp_core("will", "core.will") will = imp_core("will", "core.will")
we = imp_core("willexecutors", "core.willexecutors") _we = imp_core("willexecutors", "core.willexecutors")
# 2) GUI module imports. # 2) GUI module imports.
qt = imp_gui() qt = imp_gui()
# 3) Behaviour checks (pure logic, must be identical across versions). # 3) Behaviour checks (pure logic, must be identical across versions).
BalTimestamp = bal.BalTimestamp bal_timestamp = bal.BalTimestamp
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d" assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y" assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str" assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
Util = util.Util util_cls = util.Util
assert Util.is_perc("50%") is True assert util_cls.is_perc("50%") is True
assert Util.is_perc("100") is False assert util_cls.is_perc("100") is False
assert Util.text_to_hex("BAL") == "42414c" assert util_cls.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL" assert util_cls.hex_to_text("42414c") == "BAL"
assert Util.int_locktime(days=1) == 86400 assert util_cls.int_locktime(days=1) == 86400
# heirs constants must keep the same column layout (very delicate!) # heirs constants must keep the same column layout (very delicate!)
assert heirs.HEIR_ADDRESS == 0 assert heirs.HEIR_ADDRESS == 0

View File

@@ -27,19 +27,19 @@ Run:
tests/test_anticipate_manual_locktime.py -q tests/test_anticipate_manual_locktime.py -q
""" """
import sys
import os
import copy import copy
import os
import sys
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 # noqa: E402 import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
from bal.core.will import ( # noqa: E402 from bal.core.will import ( # noqa: E402
WillItem,
Will,
NotCompleteWillException, NotCompleteWillException,
Will,
WillExpiredException, WillExpiredException,
WillItem,
) )
# A valid serialized tx (1 input + 1 output, version 2). # A valid serialized tx (1 input + 1 output, version 2).

View File

@@ -22,13 +22,13 @@ whether a fix is needed. Run:
python3 -m pytest tests/test_anticipate_past_locktime.py -q python3 -m pytest tests/test_anticipate_past_locktime.py -q
""" """
import sys
import os import os
import sys
import time import time
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))
from bal.core.util import Util, LOCKTIME_THRESHOLD from bal.core.util import LOCKTIME_THRESHOLD, Util
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -0,0 +1,178 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_checkalive.py
"""
import sys
import time
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
# ------------------------------------------------------------------ #
# CheckAliveError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ #
# resolve_date_to_check
# ------------------------------------------------------------------ #
def test_basic_mode_uses_now():
fake_now = 1_800_000_000.0
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
def test_advanced_mode_uses_threshold_absolute():
threshold = time.time() + 5 * 86400
settings = {"threshold": threshold}
result = resolve_date_to_check(False, settings)
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# A relative threshold means "N days BEFORE the delivery": it resolves
# against the stored locktime (backwards), not forward from now.
from datetime import datetime, timedelta
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
result = resolve_date_to_check(False, settings, now=fake_now)
# date_to_check = (locktime, midnight-normalised) - 30 days.
expected = (datetime.fromtimestamp(locktime)
.replace(hour=0, minute=0, second=0, microsecond=0)
- timedelta(days=30)).timestamp()
assert abs(result - expected) < 1
# 90d delivery with a 30d window: the window starts 60 days after now.
assert result > fake_now
def test_advanced_mode_relative_threshold_anchored_to_locktime():
"""A relative threshold never drifts with the clock: re-resolving it a day
later, with the same fixed absolute locktime, yields the same date."""
fake_now = 1_800_000_000.0
locktime = fake_now + 90 * 86400
settings = {"threshold": "30d", "locktime": locktime}
first = resolve_date_to_check(False, settings, now=fake_now)
# Next day: same stored settings (the fixed delivery), a later clock.
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
assert first == second
def test_advanced_mode_relative_threshold_with_relative_locktime():
"""A relative locktime is resolved against 'now' first, then the relative
threshold counts N days back from it (matches the settings widget)."""
from datetime import datetime
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
# Recompute the expected value with the same resolution rules:
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
assert result > fake_now
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
# Without a locktime reference, fall back to the legacy forward resolution.
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
"""A RELATIVE stored locktime is anchored to the built will's frozen
delivery date (built_locktime), not to "now": an unchanged will must not
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
frozen = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
settings = {"threshold": "30d", "locktime": "2y"}
# On build day the frozen delivery is authoritative: date_to_check is
# frozen - 30d and NEVER drifts, however much later the clock gets.
first = resolve_date_to_check(
False, settings, now=1_800_000_000.0, built_locktime=frozen
)
assert abs(first - (frozen - 30 * 86400)) < 1
later = resolve_date_to_check(
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
)
assert first == later
# The check window must start BEFORE the frozen delivery (never expired).
assert first < frozen
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
"""Without a built will there is no anchor: keeps the legacy now-based
resolution (a moving target, used only before the first build)."""
from datetime import datetime
from bal.core.plugin_base import BalTimestamp
fake_now = 1_800_000_000.0
settings = {"threshold": "30d", "locktime": "90d"}
result = resolve_date_to_check(False, settings, now=fake_now)
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
assert abs(result - expected) < 1
# ------------------------------------------------------------------ #
# check_alive_expired
# ------------------------------------------------------------------ #
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core checkalive tests passed")

View File

@@ -9,23 +9,34 @@ Run:
python3 tests/test_core_heirs.py python3 tests/test_core_heirs.py
""" """
import sys
import os import os
import sys
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))
from bal.core.heirs import ( from bal.core.heirs import (
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, HEIR_ADDRESS,
HEIR_DUST_AMOUNT, TRANSACTION_LABEL, HEIR_AMOUNT,
create_op_return_script, HEIR_DUST_AMOUNT,
HEIR_LOCKTIME,
HEIR_REAL_AMOUNT,
OP_RETURN_PREFIX,
TRANSACTION_LABEL,
AliasNotFoundException, AliasNotFoundException,
NotAnAddress, AmountNotValid, LocktimeNotValid, AmountNotValid,
HeirExpiredException, HeirAmountIsDustException,
NoHeirsException, WillExecutorFeeException,
BalanceTooLowException, BalanceTooLowException,
HeirAmountIsDustException,
Heirs, Heirs,
LocktimeNotValid,
NoHeirsException,
NotAnAddress,
WillExecutorFeeException,
create_op_return_script,
get_op_return_hex,
is_op_return_address,
validate_op_return_hex,
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Constants # Constants
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -68,7 +79,7 @@ def test_op_return_empty():
def test_op_return_too_big(): def test_op_return_too_big():
try: try:
create_op_return_script("ab" * 81) # 81 bytes > max 80 create_op_return_script("ab" * 81) # 81 bytes > max 80
assert False, "expected ValueError" raise AssertionError("expected ValueError")
except ValueError: except ValueError:
pass pass
@@ -177,13 +188,13 @@ def test_validate_amount():
# Invalid # Invalid
try: try:
Heirs.validate_amount("0.000000001") Heirs.validate_amount("0.000000001")
assert False, "expected AmountNotValid" raise AssertionError("expected AmountNotValid")
except AmountNotValid: except AmountNotValid:
pass pass
try: try:
Heirs.validate_amount("-1") Heirs.validate_amount("-1")
assert False, "expected AmountNotValid" raise AssertionError("expected AmountNotValid")
except AmountNotValid: except AmountNotValid:
pass pass
@@ -207,7 +218,7 @@ def test_validate_locktime_expired():
past = int(time.time()) - 86400 # yesterday past = int(time.time()) - 86400 # yesterday
try: try:
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1) Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
assert False, "expected LocktimeNotValid" raise AssertionError("expected LocktimeNotValid")
except LocktimeNotValid: except LocktimeNotValid:
pass pass
@@ -258,9 +269,99 @@ def test_validate_removes_invalid():
assert "alice" in result or True # may or may not pass address check assert "alice" in result or True # may or may not pass address check
# ------------------------------------------------------------------ #
# OP_RETURN helpers
# ------------------------------------------------------------------ #
def test_op_return_prefix_constant():
assert OP_RETURN_PREFIX == "OP_RETURN:"
def test_is_op_return_address():
assert is_op_return_address("OP_RETURN:48656c6c6f")
assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
assert not is_op_return_address("")
assert not is_op_return_address("OP_RETURN")
assert not is_op_return_address("OP_RETURNX:")
def test_get_op_return_hex():
assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f"
assert get_op_return_hex("bc1q...") is None
assert get_op_return_hex("") is None
def test_validate_op_return_hex_valid():
validate_op_return_hex("48656c6c6f")
def test_validate_op_return_hex_invalid():
try:
validate_op_return_hex("nothex!!")
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
def test_validate_op_return_hex_too_long():
try:
validate_op_return_hex("ab" * 81)
raise AssertionError("expected NotAnAddress")
except NotAnAddress:
pass
def test_validate_op_return_hex_empty():
validate_op_return_hex("")
def test_validate_address_op_return():
addr = "OP_RETURN:48656c6c6f"
result = Heirs.validate_address(addr)
assert result == addr
def test_validate_heir_op_return():
k = "test_op_return"
v = ["OP_RETURN:48656c6c6f", "0", "30d"]
result = Heirs.validate_heir(k, v)
assert result[0] == "OP_RETURN:48656c6c6f"
assert result[1] == "0"
# ------------------------------------------------------------------ #
# Heirs class OP_RETURN integration
# ------------------------------------------------------------------ #
def test_heirs_fixed_percent_skips_op_return():
class FakeWallet:
class FakeDB:
def __init__(self):
self._data = {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
def __init__(self):
self.db = self.FakeDB()
self.dust_threshold = lambda: 500
wallet = FakeWallet()
heirs = Heirs(wallet)
heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"]
heirs["normal"] = ["addr1", "10000", "9999999999"]
fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = (
heirs.fixed_percent_lists_amount(0, 500)
)
assert "op_ret" in fixed_h
assert "normal" in fixed_h
assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0
assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000
assert fixed_amt == 10000 # OP_RETURN adds 0
if __name__ == "__main__": if __name__ == "__main__":
for name in sorted(dir()): for name in sorted(dir()):
if name.startswith("test_"): if name.startswith("test_"):
globals()[name]() globals()[name]()
print(f" [OK] {name}") print(f" [OK] {name}")
print(f"[OK] All heirs tests passed") print("[OK] All heirs tests passed")

View File

@@ -8,19 +8,20 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
""" """
import sys
import os import os
import sys
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
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))
from bal.core.heirs import ( from bal.core.heirs import (
Heirs, create_op_return_script, reduce_outputs, HEIR_AMOUNT,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT, Heirs,
create_op_return_script,
reduce_outputs,
) )
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Heirs db-dependent methods # Heirs db-dependent methods
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -139,6 +140,7 @@ def test_prepare_lists_mixed_dust_continues():
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"], "dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
}) })
raised = False raised = False
result = None
try: try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet) result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException: except HeirAmountIsDustException:
@@ -165,6 +167,7 @@ def test_prepare_lists_multi_locktime_continues():
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"], "late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
}) })
raised = False raised = False
result = None
try: try:
result, _onlyfixed = h.prepare_lists(5200, 100, wallet) result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
except HeirAmountIsDustException: except HeirAmountIsDustException:
@@ -188,7 +191,7 @@ def test_validate_address_invalid():
from bal.core.heirs import NotAnAddress from bal.core.heirs import NotAnAddress
try: try:
Heirs.validate_address("bad") Heirs.validate_address("bad")
assert False, "should have raised" raise AssertionError("should have raised")
except NotAnAddress: except NotAnAddress:
pass pass

View File

@@ -0,0 +1,165 @@
"""
Tests for ``bal.core.input_rules`` (pure, GUI-free).
Covers the locktime acceptance bounds, the RAW locktime sanitisation, and the
percentage-or-amount field normalisation that the Qt editors wrap.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_input_rules.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.input_rules import ( # noqa: E402 (path insert above)
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
replace_dy_suffixes,
)
# ------------------------------------------------------------------ #
# LockTimeEditor
# ------------------------------------------------------------------ #
def test_locktime_editor_is_acceptable():
assert LockTimeEditor.is_acceptable_locktime(100) is True
assert LockTimeEditor.is_acceptable_locktime(0) is True
assert LockTimeEditor.is_acceptable_locktime(-1) is False
assert LockTimeEditor.is_acceptable_locktime(None) is True
def test_locktime_editor_is_acceptable_string():
assert LockTimeEditor.is_acceptable_locktime("100") is True
assert LockTimeEditor.is_acceptable_locktime("abc") is False
assert LockTimeEditor.is_acceptable_locktime("") is True
def test_locktime_editor_min_max():
assert LockTimeEditor.min_allowed_value >= 0
assert LockTimeEditor.max_allowed_value > LockTimeEditor.min_allowed_value
def test_locktime_editor_get_max_allowed_timestamp():
# Always a valid, fromtimestamp-able value (the Windows 2038 clamp).
ts = LockTimeEditor.get_max_allowed_timestamp()
import datetime
datetime.datetime.fromtimestamp(ts) # must not raise
assert ts <= 2**32 - 1
def test_locktime_editor_subclass_bounds():
class Tight(LockTimeEditor):
min_allowed_value = 1000
max_allowed_value = 2000
assert Tight.is_acceptable_locktime(1500) is True
assert Tight.is_acceptable_locktime(999) is False
assert Tight.is_acceptable_locktime(2001) is False
# ------------------------------------------------------------------ #
# replace_dy_suffixes / RAW locktime sanitisation
# ------------------------------------------------------------------ #
def test_replace_dy_suffixes():
# 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).
assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix)
assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains
assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_normalize_locktime_raw_empty():
s, isdays, isyears, pos = normalize_locktime_raw_text("", 0)
assert s == ""
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_days():
s, isdays, isyears, pos = normalize_locktime_raw_text("30d", 3)
assert s == "30d"
assert isdays is True
assert isyears is False
def test_normalize_locktime_raw_years():
s, isdays, isyears, pos = normalize_locktime_raw_text("2y", 2)
assert s == "2y"
assert isdays is False
assert isyears is True
def test_normalize_locktime_raw_strips_bad_chars():
s, isdays, isyears, pos = normalize_locktime_raw_text("12a34b56", 8)
assert s == "123456"
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_single_suffix():
# "1y30d" collapses to a single suffix; "d" wins (processed first), so the
# "y" is dropped along with all letters.
s, isdays, isyears, pos = normalize_locktime_raw_text("1y30d", 5)
assert isdays is True
assert isyears is False
assert s == "130d"
# ------------------------------------------------------------------ #
# Percentage-or-amount normalisation
# ------------------------------------------------------------------ #
def test_perc_normalize_percent():
s, is_perc = normalize_perc_amount_text("50%", ".")
assert is_perc is True
assert s == "50%"
def test_perc_normalize_no_percent():
s, is_perc = normalize_perc_amount_text("123", ".")
assert is_perc is False
assert s == "123"
def test_perc_normalize_strips_invalid():
s, is_perc = normalize_perc_amount_text("1a2b3", ".")
assert s == "123"
def test_perc_normalize_decimal_limit():
# At most 8 decimals after the point.
s, is_perc = normalize_perc_amount_text("1.1234567890123", ".")
assert s == "1.12345678"
def test_perc_parse():
from decimal import Decimal
assert parse_perc_amount("50%", ".") == Decimal(50)
assert parse_perc_amount("123.45", ".") == Decimal("123.45")
assert parse_perc_amount("abc", ".") is None
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core input_rules tests passed")

View File

@@ -8,14 +8,15 @@ Run:
python3 tests/test_core_plugin_base.py python3 tests/test_core_plugin_base.py
""" """
import sys
import os import os
import sys
import time import time
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))
from datetime import datetime, date, timedelta from datetime import date, datetime, timedelta
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# BalTimestamp # BalTimestamp
@@ -206,7 +207,7 @@ def test_default_will_settings_relative():
def test_default_will_settings(): def test_default_will_settings():
settings = BalPlugin.default_will_settings() settings = BalPlugin.default_will_settings()
assert settings["baltx_fees"] == 100 assert settings["baltx_fees"] == 20
assert "threshold" in settings assert "threshold" in settings
assert "locktime" in settings assert "locktime" in settings
# threshold/locktime should be absolute timestamps # threshold/locktime should be absolute timestamps
@@ -228,7 +229,7 @@ def test_validate_will_settings():
# Note: passing None triggers `will_settings = []` which then fails # Note: passing None triggers `will_settings = []` which then fails
# on .get(). This is a latent bug — test passing a dict directly # on .get(). This is a latent bug — test passing a dict directly
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0}) result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
assert result["baltx_fees"] == 100 assert result["baltx_fees"] == 20
# normal settings unchanged # normal settings unchanged
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000} input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}

View File

@@ -0,0 +1,299 @@
"""
Tests for ``bal.core.reminders`` (pure, GUI-free).
Covers the reminder-offset rules (BASIC + ADVANCED), the RFC-5545 helpers
(format_time, ical_escape, fold_ical_line), write_temp_ics, and the unified
``build_ics_reminders`` builder.
This module imports only ``bal.core``, so it runs in the lint venv (no
Electrum/Qt needed) as well as the runtime venv:
Run:
python3 tests/test_core_reminders.py
"""
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.reminders import ( # noqa: E402 (path insert above)
BASIC_REMINDER_OFFSETS,
basic_reminder_offsets,
build_ics_reminders,
compute_reminder_offsets,
fold_ical_line,
format_time,
ical_escape,
write_temp_ics,
)
# ------------------------------------------------------------------ #
# compute_reminder_offsets - distribution rules
# ------------------------------------------------------------------ #
def test_offsets_long_period_three_reminders():
"""30-day period, 3 reminders: spread out, all before the deadline."""
offsets = compute_reminder_offsets(30, 3)
assert len(offsets) == 3
# All strictly before the deadline (offset >= 1 means "n days before end").
assert all(o >= 1 for o in offsets)
# Sorted earliest-first (largest offset first).
assert offsets == sorted(offsets, reverse=True)
# One reminder near the start, one near the end.
assert max(offsets) == 30
assert min(offsets) == 1
def test_offsets_capped_at_one_per_day():
"""Short period: at most one reminder per available day."""
# 2 days but 3 requested -> only 2 reminders, one per day.
assert compute_reminder_offsets(2, 3) == [2, 1]
# 1 day but 3 requested -> a single reminder, the day before the deadline.
assert compute_reminder_offsets(1, 3) == [1]
def test_offsets_empty_when_no_room():
"""No reminders when there is no day before the deadline."""
assert compute_reminder_offsets(0, 3) == []
assert compute_reminder_offsets(-5, 3) == []
# A non-positive count also yields nothing.
assert compute_reminder_offsets(30, 0) == []
def test_offsets_never_exceed_requested_count():
"""The number of reminders never exceeds the requested count (max 5)."""
offsets = compute_reminder_offsets(100, 5)
assert len(offsets) == 5
assert all(o >= 1 for o in offsets)
# Distinct offsets only (no duplicate alarms on the same day).
assert len(set(offsets)) == len(offsets)
def test_offsets_single_reminder_is_day_before_deadline():
"""A single requested reminder fires one day before the deadline."""
assert compute_reminder_offsets(30, 1) == [1]
# ------------------------------------------------------------------ #
# basic_reminder_offsets - fixed BASIC-mode offsets
# ------------------------------------------------------------------ #
def test_basic_offsets_full_period():
"""A far-away delivery keeps all three fixed offsets (30, 10, 1)."""
assert basic_reminder_offsets(365) == [30, 10, 1]
def test_basic_offsets_truncated_by_horizon():
"""Offsets beyond the delivery horizon are dropped."""
assert basic_reminder_offsets(20) == [10, 1]
assert basic_reminder_offsets(5) == [1]
def test_basic_offsets_empty():
"""No future offsets when the delivery is less than a day away."""
assert basic_reminder_offsets(0) == []
assert basic_reminder_offsets(-10) == []
def test_basic_offsets_always_from_fixed_set():
"""Every returned offset is one of the fixed BASIC offsets."""
for horizon in range(0, 40):
result = basic_reminder_offsets(horizon)
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=tz)
assert format_time(dt) == "20250601T103045Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
assert ical_escape("hello world") == "hello world"
def test_ical_escape_backslash():
assert ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\nline2"
result = ical_escape(text)
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
assert ical_escape("\\;,") == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
assert fold_ical_line("SUMMARY:Test") == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "SUMMARY:" + "a" * 100
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
def test_fold_ical_line_unicode():
line = "SUMMARY:" + "é" * 50
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ #
# build_ics_reminders - unified builder
# ------------------------------------------------------------------ #
_LOCKTIME = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
def _common_kwargs():
return dict(
locktime=_LOCKTIME,
description="BAL will of $wallet_name\r\n$heirs_complete",
summary="BAL - Will execution of $wallet_name",
wallet_name="karen7",
heirs_details=" alice - bc1qalice, bob - bc1qbob",
version="0.6.1",
)
def test_build_ics_advanced_separate_events():
"""ADVANCED: 30-day period -> offsets [30, 16, 1], one VEVENT each."""
content = build_ics_reminders(
basic_mode=False,
num_reminders=3,
now=datetime(2026, 7, 1, 0, 0, 0, tzinfo=timezone.utc),
threshold=datetime(2026, 6, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
assert content.startswith("BEGIN:VCALENDAR")
assert content.endswith("\r\n")
lines = content.split("\r\n")
assert lines.count("BEGIN:VEVENT") == 3
assert lines.count("END:VEVENT") == 3
assert "BEGIN:VALARM" not in lines
# Unique UIDs, numbered summaries, last event one day before the deadline.
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == len(set(uids)) == 3
assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines)
last_dt = format_time(_LOCKTIME - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines
# Template substitution; the CRLF inside the description stays as a line
# break in the folded DESCRIPTION (no literal "\n" escaping in ical_escape).
desc = [ln for ln in lines if ln.startswith("DESCRIPTION:")]
assert desc and "karen7" in desc[0]
assert any(ln.lstrip().startswith("alice") for ln in lines)
def test_build_ics_basic_uses_fixed_offsets():
"""BASIC: fixed offsets (30, 10, 1) filtered to the future, threshold not
required."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
lines = content.split("\r\n")
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == 3
# The first event is the 30-day offset.
assert "bal-karen7-30d" in uids[0]
def test_build_ics_returns_none_when_no_reminders():
"""No future reminder -> None (the aligned behavior), not an empty file."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 7, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is None
def test_build_ics_advanced_requires_threshold():
"""ADVANCED mode without a threshold is a programming error."""
try:
build_ics_reminders(basic_mode=False, **_common_kwargs())
except ValueError:
return
raise AssertionError("expected ValueError for missing threshold in ADVANCED")
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core reminders tests passed")

View File

@@ -9,13 +9,14 @@ Run:
python3 tests/test_core_util.py python3 tests/test_core_util.py
""" """
import sys
import os import os
import sys
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 import pytest # pyright: ignore[reportMissingImports]
from bal.core.util import Util, LOCKTIME_THRESHOLD from bal.core.util import Util
def test_locktime_to_str(): def test_locktime_to_str():
@@ -40,7 +41,7 @@ def test_str_to_locktime():
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid # the block-height suffix "b" was removed (A1): "144b" is no longer a valid
# relative locktime, so it is NOT passed through unchanged. # relative locktime, so it is NOT passed through unchanged.
with pytest.raises(Exception): with pytest.raises(ValueError):
Util.str_to_locktime("144b") Util.str_to_locktime("144b")
# integer string -> int # integer string -> int
@@ -84,6 +85,63 @@ def test_int_locktime():
assert Util.int_locktime() == 0 assert Util.int_locktime() == 0
def test_relative_days():
assert Util._relative_days("30d") == 30
assert Util._relative_days("1y") == 365
assert Util._relative_days("2y") == 730
assert Util._relative_days("30D") == 30
assert Util._relative_days(1700000000) is None
assert Util._relative_days("1700000000") is None
assert Util._relative_days("garbage") is None
def test_resolve_locktime_against_tx_absolute():
"""An absolute current date is returned unchanged (compared vs the tx)."""
frozen = 1817438400
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
def test_resolve_locktime_against_tx_unchanged_relative():
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
(coherent), instead of drifting one day per day away from it."""
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
assert resolved == frozen
def test_resolve_locktime_against_tx_lengthened():
"""A lengthened relative recipe resolves later than the frozen tx locktime
(this is what the postpone check uses to trigger invalidation)."""
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
assert resolved == frozen + 365 * 86400
def test_resolve_locktime_against_tx_shortened():
"""A shortened relative recipe resolves earlier than the frozen tx locktime
(this is what the anticipate/rebuild path uses)."""
frozen = 1817438400
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
assert resolved < frozen
def test_resolve_locktime_against_tx_no_relative_anchor():
"""When the built recipe was absolute there is no anchor: falls back to the
legacy forward-from-now resolution (returns a timestamp, no crash)."""
frozen = 1817438400
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
assert isinstance(result, int)
assert result > 1700000000
def test_resolve_locktime_against_tx_zero_tx_locktime():
"""A tx with no usable locktime falls back to now-based resolution."""
result = Util.resolve_locktime_against_tx("1y", "1y", 0)
assert isinstance(result, int)
assert result > 1700000000
def test_encode_decode_amount(): def test_encode_decode_amount():
dp = 8 # typical BTC decimal point dp = 8 # typical BTC decimal point
@@ -347,44 +405,44 @@ def test_in_utxo():
def test_cmp_output(): def test_cmp_output():
class O: class Obj:
def __init__(self, addr, val): def __init__(self, addr, val):
self.address = addr self.address = addr
self.value = val self.value = val
assert Util.cmp_output(O("a", 100), O("a", 100)) is True assert Util.cmp_output(Obj("a", 100), Obj("a", 100)) is True
assert Util.cmp_output(O("a", 100), O("b", 100)) is False assert Util.cmp_output(Obj("a", 100), Obj("b", 100)) is False
assert Util.cmp_output(O("a", 100), O("a", 200)) is False assert Util.cmp_output(Obj("a", 100), Obj("a", 200)) is False
def test_in_output(): def test_in_output():
class O: class Obj:
def __init__(self, addr, val): def __init__(self, addr, val):
self.address = addr self.address = addr
self.value = val self.value = val
outputs = [O("a", 100), O("b", 200)] outputs = [Obj("a", 100), Obj("b", 200)]
assert Util.in_output(O("a", 100), outputs) is True assert Util.in_output(Obj("a", 100), outputs) is True
assert Util.in_output(O("z", 999), outputs) is False assert Util.in_output(Obj("z", 999), outputs) is False
assert Util.in_output(O("a", 100), []) is False assert Util.in_output(Obj("a", 100), []) is False
def test_din_output(): def test_din_output():
class O: class Obj:
def __init__(self, addr, val): def __init__(self, addr, val):
self.address = addr self.address = addr
self.value = val self.value = val
outputs = [O("a", 100), O("b", 200)] outputs = [Obj("a", 100), Obj("b", 200)]
# same amount AND same address # same amount AND same address
same_amt, same_addr = Util.din_output(O("a", 100), outputs) same_amt, same_addr = Util.din_output(Obj("a", 100), outputs)
assert same_amt is True and same_addr is True assert same_amt is True and same_addr is True
# same amount but different address # same amount but different address
same_amt, same_addr = Util.din_output(O("c", 100), outputs) same_amt, same_addr = Util.din_output(Obj("c", 100), outputs)
assert same_amt is True and same_addr is False assert same_amt is True and same_addr is False
# different amount # different amount
same_amt, same_addr = Util.din_output(O("z", 999), outputs) same_amt, same_addr = Util.din_output(Obj("z", 999), outputs)
assert same_amt is False and same_addr is False assert same_amt is False and same_addr is False
@@ -439,6 +497,13 @@ if __name__ == "__main__":
test_str_to_locktime() test_str_to_locktime()
test_parse_locktime_string() test_parse_locktime_string()
test_int_locktime() test_int_locktime()
test_relative_days()
test_resolve_locktime_against_tx_absolute()
test_resolve_locktime_against_tx_unchanged_relative()
test_resolve_locktime_against_tx_lengthened()
test_resolve_locktime_against_tx_shortened()
test_resolve_locktime_against_tx_no_relative_anchor()
test_resolve_locktime_against_tx_zero_tx_locktime()
test_encode_decode_amount() test_encode_decode_amount()
test_is_perc() test_is_perc()
test_cmp_array() test_cmp_array()

View File

@@ -8,13 +8,13 @@ Run:
python3 tests/test_core_will.py python3 tests/test_core_will.py
""" """
import sys
import os
import copy import copy
import os
import sys
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))
from bal.core.will import WillItem, Will from bal.core.will import Will, WillItem
from bal.core.willexecutors import Willexecutors
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2) # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = ( _VALID_TX_HEX = (
@@ -332,11 +332,19 @@ def test_will_check_tx_height():
def test_exceptions(): def test_exceptions():
from bal.core.will import ( from bal.core.will import (
WillException, WillExpiredException, NotCompleteWillException, AmountException,
HeirChangeException, TxFeesChangedException, HeirNotFoundException, FixedAmountException,
WillexecutorChangeException, NoWillExecutorNotPresent, HeirChangeException,
WillExecutorNotPresent, NoHeirsException, HeirNotFoundException,
AmountException, PercAmountException, FixedAmountException, NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
PercAmountException,
TxFeesChangedException,
WillException,
WillexecutorChangeException,
WillExecutorNotPresent,
WillExpiredException,
WillPostponedException, WillPostponedException,
) )
@@ -375,4 +383,4 @@ if __name__ == "__main__":
if name.startswith("test_"): if name.startswith("test_"):
globals()[name]() globals()[name]()
print(f" [OK] {name}") print(f" [OK] {name}")
print(f"[OK] All Will tests passed") print("[OK] All Will tests passed")

View File

@@ -8,14 +8,28 @@ Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
""" """
import sys
import os import os
from unittest.mock import MagicMock, patch, PropertyMock, call import sys
from binascii import unhexlify
from unittest.mock import MagicMock, patch
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))
from electrum import crypto
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.bitcoin import public_key_to_p2wpkh
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
Transaction,
TxOutpoint,
)
from bal.core.util import Util
from bal.core.will import Will, WillItem from bal.core.will import Will, WillItem
from electrum.transaction import Transaction
_VALID_TX_HEX = ( _VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
@@ -170,6 +184,8 @@ def test_mempool_status_clears_valid():
def test_check_invalidated_invalidated(): def test_check_invalidated_invalidated():
wallet = MagicMock() wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1 wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
# The funding is really consumed by a broadcast tx -> the will is dead.
wallet.adb.get_spender.return_value = "ab" * 32
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "", "willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}) "time": 0, "change": "", "baltx_fees": 100})
@@ -185,6 +201,9 @@ def test_check_invalidated_invalidated():
def test_check_will(): def test_check_will():
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
# No broadcast tx spends the funding: the missing UTXO is only a local
# (history) artifact, so the will is not invalidated by it.
wallet.adb.get_spender.return_value = None
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]}, item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "", "willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}) "time": 0, "change": "", "baltx_fees": 100})
@@ -196,6 +215,141 @@ def test_check_will():
assert item.get_status("MEMPOOL") is True assert item.get_status("MEMPOOL") is True
# ------------------------------------------------------------------ #
# WillItem signature counts + PARTIALLY_SIGNED status
# ------------------------------------------------------------------ #
def _multisig_descriptor():
"""Return a 2-of-3 wsh multisig descriptor and its three pubkeys."""
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
return parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs))), pubs
def _make_multisig_ptx(nsigs, locktime=None):
"""A 2-of-3 wsh multisig PartialTransaction carrying ``nsigs`` signatures."""
desc, pubs = _multisig_descriptor()
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
txout = PartialTxOutput.from_address_and_value(addr, 50000)
ptx = PartialTransaction()
if locktime is not None:
ptx.locktime = locktime
ptx.add_inputs([txin])
ptx.add_outputs([txout])
return ptx
def _make_multisig_willitem(nsig):
"""A WillItem wrapping an unsigned 2-of-3 partial tx with ``nsig`` sigs.
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization (PSBT serialization drops the descriptor but keeps
the signatures), mirroring the real load-from-wallet flow.
"""
w = {"tx": _make_multisig_ptx(nsig), "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}
item = WillItem(w, _id="mswill")
item.tx.inputs()[0].script_descriptor = _multisig_descriptor()[0]
return item
def test_willitem_sigs_fields_roundtrip():
item = _make_multisig_willitem(1)
assert item.sigs_required == 0
assert item.sigs_have == 0
item.sigs_required = 2
item.sigs_have = 1
item.set_status("PARTIALLY_SIGNED", True)
d = item.to_dict()
assert d["sigs_required"] == 2
assert d["sigs_have"] == 1
assert d["PARTIALLY_SIGNED"] is True
item2 = WillItem(d, _id="mswill")
assert item2.sigs_required == 2
assert item2.sigs_have == 1
assert item2.get_status("PARTIALLY_SIGNED") is True
def test_willitem_legacy_dict_defaults_sig_fields():
# A will saved before the signature-tracking feature has no sig fields:
# they must default to 0 and the flag to False.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}, _id="legacy")
assert item.sigs_required == 0
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
def test_willitem_partial_signed_keeps_valid():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
assert "Partially Signed" in item.status
def test_willitem_complete_clears_partially_signed():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
assert item.get_status("COMPLETE") is True
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_partial():
item = _make_multisig_willitem(1)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 1
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
def test_check_signatures_unsigned_not_partial():
item = _make_multisig_willitem(0)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 0
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_fully_signed_clears_flag():
item = _make_multisig_willitem(2)
item.set_status("PARTIALLY_SIGNED", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_complete_item_clears_flag():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_single_sig_required():
# A single-signature (P2WPKH) will needs exactly 1 signature: 0 present is
# "New", not "partially signed".
pub = crypto.privkey_to_pubkey(bytes([7] * 32)).hex()
item = _make_multisig_willitem(0)
item.tx.inputs()[0].script_descriptor = parse_descriptor("wpkh({})".format(pub))
Will.check_signatures({"mswill": item})
assert item.sigs_required == 1
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# WillItem.__init__ with wallet # WillItem.__init__ with wallet
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -217,6 +371,472 @@ def test_willitem_init_without_wallet():
assert item is not None assert item is not None
# ------------------------------------------------------------------ #
# Will.save_valid_transactions_to_history (history persistence)
# ------------------------------------------------------------------ #
class FakeTxMinedStatus:
def __init__(self, height):
self._height = height
def height(self):
return self._height
class FakeTxInfo:
def __init__(self, height):
self.tx_mined_status = FakeTxMinedStatus(height)
class FakeWallet:
"""Minimal stand-in for an Electrum wallet used by history persistence."""
def __init__(self, stored_txs=None, spenders=None, heights=None, outputs=None,
addresses=None):
self.adb = FakeADB(
stored_txs or {},
spenders=spenders,
heights=heights,
outputs=outputs,
)
self.db = self.adb.db
self.labels = {}
self.save_db_called = 0
self.addresses = list(addresses or [])
def set_label(self, txid, label):
if label is None:
self.labels.pop(txid, None)
else:
self.labels[txid] = label
def get_all_labels(self):
return dict(self.labels)
def save_db(self):
self.save_db_called += 1
def get_addresses(self):
return self.addresses
def get_label_for_txid(self, txid):
return self.labels.get(txid, "")
def get_tx_info(self, tx):
height = self.adb.heights.get(tx.txid(), TX_HEIGHT_LOCAL)
return FakeTxInfo(height)
def get_utxos(self):
utxos = []
for outs in self.adb.outputs.values():
for utxo in outs.values():
if utxo.spent_height is None:
utxos.append(utxo)
return utxos
class FakeADB:
def __init__(self, stored_txs, spenders=None, heights=None, outputs=None):
self.db = FakeDB(stored_txs)
self.added = []
self.removed = []
self.spenders = dict(spenders or {})
self.heights = dict(heights or {})
self.outputs = dict(outputs or {})
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
self.added.append((tx, allow_unrelated))
return True
def remove_transaction(self, txid):
self.removed.append(txid)
def get_spender(self, outpoint):
txid = self.spenders.get(outpoint)
if txid is None:
return None
height = self.heights.get(txid, TX_HEIGHT_LOCAL)
if height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return None
return txid
def get_tx_height(self, txid):
return FakeTxMinedStatus(self.heights.get(txid, TX_HEIGHT_LOCAL))
def get_addr_outputs(self, addr):
return self.outputs.get(addr, {})
class FakeDB:
def __init__(self, stored_txs):
self.stored = dict(stored_txs)
def get_transaction(self, txid):
return self.stored.get(txid)
def _make_simple_willitem(tx, valid=True, we_url=None):
"""A WillItem wrapping *tx* with an optional VALID status and executor."""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
return WillItem(w, _id="wid")
def _exec_label(template, url):
return template.replace("{willexecutor}", url)
def test_save_incomplete_valid_tx_to_history_adds_and_labels():
# A still-unsigned / partially-signed ("New") partial tx is stored in the
# local history, tagged with the decoded label.
tx = _make_multisig_ptx(1)
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert [t.txid() for t, _ in wallet.adb.added] == [tx.txid()]
txid = tx.txid()
assert wallet.labels[txid] == "BitcoinAfterLife inheritance transaction - https://we.example"
assert wallet.save_db_called == 1
def test_save_skips_complete_and_invalid_items():
# A fully-signed ("Complete") tx must NOT be stored: it is removed from the
# local history instead. Invalid items are never touched.
complete = _make_multisig_willitem(2)
complete.set_status("VALID", True)
invalid = _make_simple_willitem(Transaction(_VALID_TX_HEX), valid=False)
new = _make_simple_willitem(_make_multisig_ptx(1), valid=True)
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"complete": complete, "invalid": invalid, "new": new},
wallet,
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
assert [t.txid() for t, _ in wallet.adb.added] == [new.tx.txid()]
assert len(wallet.labels) == 1
assert wallet.labels[new.tx.txid()] == (
"BitcoinAfterLife inheritance transaction - "
)
def test_save_combines_sigs_when_stored_partial():
# The tx to save is already present in the wallet as an incomplete partial
# PSBT: the signatures are combined into it instead of blindly overwriting.
# (Exercised on raw PartialTransactions; through WillItem a complete tx
# round-trips to a plain Transaction, which overwrites instead - see
# test_save_complete_tx_overwrites_stored_partial.)
our = _make_multisig_ptx(2)
stored_partial = _make_multisig_ptx(1)
wallet = FakeWallet(stored_txs={our.txid(): stored_partial})
Will._add_transaction_to_history(wallet, our, our.txid())
assert len(wallet.adb.added) == 1
saved, _ = wallet.adb.added[0]
# The combine path was taken (the stored partial was re-added, not our tx).
assert saved is stored_partial
assert saved.is_complete()
def test_save_removes_complete_item_from_history():
# A fully-signed item is removed from the local history: its matching
# entry (exact label) is deleted, and it is never re-added.
our = _make_multisig_ptx(2)
txid = our.txid()
label = "BitcoinAfterLife inheritance transaction - https://we.example"
item = _make_simple_willitem(our, valid=True, we_url="https://we.example")
wallet = FakeWallet(stored_txs={txid: our})
wallet.labels[txid] = label
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert wallet.adb.added == []
assert txid in wallet.adb.removed
assert txid not in wallet.labels
def test_save_cleanup_removes_stale_exact_label():
tx = _make_multisig_ptx(1)
txid = tx.txid()
stale_txid = "ab" * 32
other_txid = "cd" * 32
label = "BitcoinAfterLife inheritance transaction - https://we.example"
wallet = FakeWallet()
# Pre-existing wallet labels: one current, one stale (same label), one with
# a different executor URL that must be kept.
wallet.labels[txid] = label
wallet.labels[stale_txid] = label
wallet.labels[other_txid] = "BitcoinAfterLife inheritance transaction - https://other.example"
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert stale_txid in wallet.adb.removed
assert other_txid not in wallet.adb.removed
assert txid not in wallet.adb.removed
# The stale tx's label is dropped with it.
assert stale_txid not in wallet.labels
assert other_txid in wallet.labels
assert wallet.labels[txid] == label
def test_save_no_wallet_or_no_adb_is_noop():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
Will.save_valid_transactions_to_history({"wid": item}, None, "LBL")
Will.save_valid_transactions_to_history({"wid": item}, object(), "LBL")
def test_save_never_raises_on_adb_failure():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
class BoomWallet(FakeWallet):
class BoomADB:
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
raise RuntimeError("boom")
def remove_transaction(self, txid):
raise RuntimeError("boom")
def __init__(self):
self.adb = self.BoomADB()
self.labels = {}
def get_all_labels(self):
return {"stale": "BitcoinAfterLife inheritance transaction - "}
wallet = BoomWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
# No exception propagates; a fresh fake works afterwards.
assert True
def test_check_will_does_not_save_to_history():
# History persistence is no longer triggered from check_will: it runs after
# the will is signed (see the GUI hooks). check_will must not touch it.
with patch.object(Will, "save_valid_transactions_to_history") as save_mock:
Will.check_will({}, [], None, 9999999999)
save_mock.assert_not_called()
def test_is_will_valid_calls_check_will_without_history_label():
with patch.object(Will, "check_will") as cw_mock:
Will.is_will_valid({}, 9999999999, 100, [])
assert len(cw_mock.call_args[0]) == 4
assert cw_mock.call_args[0] == ({}, [], False, 9999999999)
# ------------------------------------------------------------------ #
# Signature absorption + status fixes (local-history will tx)
# ------------------------------------------------------------------ #
def _make_willitem_keyed_by_txid(tx, valid=True, we_url=None):
"""A WillItem whose ``_id`` equals its txid (as in a real built will).
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization, mirroring the real load-from-wallet flow.
"""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
item = WillItem(w, _id=tx.txid())
if isinstance(tx, PartialTransaction) and tx.inputs():
desc = getattr(tx.inputs()[0], "script_descriptor", None)
if desc is not None and isinstance(item.tx, PartialTransaction):
item.tx.inputs()[0].script_descriptor = desc
return item
def _make_local_wallet(tx, stored, funding):
"""A FakeWallet where *tx* is stored locally and consumes *funding*."""
return FakeWallet(
stored_txs={tx.txid(): stored},
spenders={funding: tx.txid()},
heights={tx.txid(): TX_HEIGHT_LOCAL},
)
def test_absorb_history_signatures_merges_and_completes():
# The wallet's stored local copy of the will tx carries more signatures
# than the in-memory item: they are merged in and the item becomes COMPLETE.
item = _make_multisig_willitem(1)
stored = _make_multisig_ptx(2)
assert stored.txid() == item.tx.txid()
wallet = FakeWallet(stored_txs={item._id: stored})
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is True
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
def test_absorb_history_signatures_noop_without_stored_copy():
# Nothing stored in the wallet: the in-memory item is left untouched.
item = _make_multisig_willitem(1)
wallet = FakeWallet()
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is False
assert item.get_status("COMPLETE") is False
def test_check_invalidated_keeps_valid_on_local_spend():
# The funding is missing from the wallet's UTXOs only because the will tx
# itself was saved into the local history: a wallet-local spender must not
# invalidate the will.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is False
assert item.get_status("VALID") is True
def test_check_invalidated_invalidates_on_real_spend():
# The funding is consumed by a broadcast transaction: the will is dead.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
ext_spender = "ab" * 32
wallet = FakeWallet(
spenders={funding: ext_spender},
heights={ext_spender: 100},
)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_search_rai_local_artifact_keeps_valid():
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
assert item.get_status("CONFIRMED") is False
def test_search_rai_confirmed_on_broadcast_spender():
# The will tx is broadcast/confirmed: its own (real) spender marks it
# CONFIRMED, not INVALIDATED.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = FakeWallet(
stored_txs={tx.txid(): tx},
spenders={funding: tx.txid()},
heights={tx.txid(): 100},
)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("CONFIRMED") is True
assert item.get_status("INVALIDATED") is False
def test_check_will_merges_history_sigs_and_stays_valid():
# End-to-end: a will tx stored in the local history gained a signature.
# check_will must absorb it, not invalidate the will for the local spend.
now = 1700000000
locktime = 2000000000
tx = _make_multisig_ptx(1, locktime)
stored = _make_multisig_ptx(2, locktime)
assert stored.txid() == tx.txid()
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, stored, funding)
Will.check_will(will, [], wallet, now)
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
# ------------------------------------------------------------------ #
# Util.get_available_utxos (UTXO-view restoration)
# ------------------------------------------------------------------ #
def _make_utxo(prevout_hex="22", idx=0, value=100000,
spent_txid=None, spent_height=None):
txin = PartialTxInput(
prevout=TxOutpoint(bytes.fromhex(prevout_hex) * 32, idx), script_sig=b""
)
txin._trusted_value_sats = value
txin.spent_txid = spent_txid
txin.spent_height = spent_height
return txin
_HISTORY_TEMPLATE = "BitcoinAfterLife inheritance transaction - {willexecutor}"
_HISTORY_LABEL = _HISTORY_TEMPLATE.replace("{willexecutor}", "https://we.example")
def _wallet_with_local_spend(locktime):
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=TX_HEIGHT_LOCAL)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=locktime)},
heights={spender: TX_HEIGHT_LOCAL},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
return wallet, utxo
def test_get_available_utxos_restores_future_bal_local_spend():
# A later-locktime BAL history tx locally spent the coin: it is restored.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert [u.prevout.to_str() for u in result] == [utxo.prevout.to_str()]
def test_get_available_utxos_does_not_restore_unlabeled_spend():
wallet, utxo = _wallet_with_local_spend(locktime=2000)
wallet.labels["ab" * 32] = "some other label"
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_not_later_locktime():
# The stored spender's locktime equals the will's locktime (same will):
# its spend is NOT ignored.
wallet, utxo = _wallet_with_local_spend(locktime=1000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_confirmed_spend():
# A broadcast (confirmed) spender is never ignored.
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=100)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
heights={spender: 100},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_none_locktime_is_raw_view():
# No reference locktime: the raw wallet.get_utxos() view is returned, so a
# locally-spent coin stays hidden.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, None)
assert result == []
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Main # Main
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,483 @@
"""
Tests for will invalidation (cancellation) in ``bal.core.will``.
Covers:
* Will.invalidate_will() - building the invalidation transaction
* Will.set_invalidate() - marking will items as invalidated (status cascade)
The invalidation ("cancellation") transaction spends the same UTXOs that were
committed to the time-locked will, making the original will transactions
unspendable. This is the mechanism used when:
* The will expires (locktime in the past)
* The owner postpones a signed/sent will to a later date
* The check-alive threshold is passed (dead-man's switch)
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_core_will_invalidate.py -q
"""
import copy
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
# Patch Transaction.add_info_from_wallet so WillItem can parse the tx hex
# without a live Electrum wallet connection.
from electrum.transaction import Transaction
from bal.core.will import Will, WillItem
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused across multiple test suites.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The prevout string that _VALID_TX_HEX spends (input 0).
_PREVOUT_STR = "3140eb24b43386f35ba69e3875eb6c93130ac66201d01c58f598defc949a5c2a:0"
# Change address for the invalidation output.
_CHANGE_ADDR = "14CHYaaByjJZpx4oHBpfDMdqhTyXnZ3kVs"
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _make_willitem(value_sats=1000000, valid=True, extra_heirs=None):
"""Create a WillItem from _VALID_TX_HEX with a known input value.
The input's ``_trusted_value_sats`` is set so that
``invalidate_will`` can read the balance from it.
"""
heirs = {"alice": ["addr_alice", 5000, "30d"]}
if extra_heirs:
heirs.update(extra_heirs)
item = WillItem({
"tx": _VALID_TX_HEX,
"heirs": heirs,
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
})
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
# Set the input value so the balance calculation works.
item.tx.inputs()[0]._trusted_value_sats = value_sats
if not valid:
item.set_status("INVALIDATED", True)
return item
def _make_utxo(prevout_str=None, value_sats=1000000, is_coinbase=False):
"""Create a minimal mock UTXO (wallet-side) matching a will input."""
if prevout_str is None:
prevout_str = _PREVOUT_STR
utxo = MagicMock()
utxo.prevout.to_str.return_value = prevout_str
utxo.is_coinbase_output.return_value = is_coinbase
utxo.block_height = 1
utxo.value_sats.return_value = value_sats
return utxo
def _mock_wallet(utxos, change_addr=_CHANGE_ADDR):
"""Create a mock wallet with the given UTXOs and change address."""
wallet = MagicMock()
wallet.get_utxos.return_value = utxos
wallet.get_change_addresses_for_new_transaction.return_value = [change_addr]
wallet.network = MagicMock()
return wallet
def _run_invalidate(will, wallet, fees_per_byte=10, current_height=800000):
"""Run ``Will.invalidate_will`` with mocked Electrum tx building.
Returns ``(result, mock_from_io, mock_out)`` so tests can inspect
the calls to ``PartialTransaction.from_io`` and
``PartialTxOutput.from_address_and_value``.
"""
mock_output = MagicMock()
mock_output.value = 0
mock_output.is_change = False
mock_tx = MagicMock()
mock_tx.txid.return_value = "invalidation_txid"
mock_tx.estimated_size.return_value = 200
with patch("bal.core.will.Util.get_current_height", return_value=current_height), \
patch("electrum.transaction.PartialTxOutput.from_address_and_value",
return_value=mock_output) as mock_out, \
patch("electrum.transaction.PartialTransaction.from_io",
return_value=mock_tx) as mock_from_io:
result = Will.invalidate_will(will, wallet, fees_per_byte)
return result, mock_from_io, mock_out
# ================================================================== #
# Will.invalidate_will - building the cancellation transaction
# ================================================================== #
class TestInvalidateWill:
"""Tests for ``Will.invalidate_will()``: the cancellation transaction."""
def test_basic_returns_tx(self):
"""A single valid will item with a matching wallet UTXO produces an
invalidation transaction."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=10)
assert result is not None, "should return a transaction"
def test_basic_rbf_enabled(self):
"""The invalidation tx has RBF (Replace-By-Fee) enabled."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate(will, wallet)
result.set_rbf.assert_called_with(True)
def test_basic_locktime_is_current_height(self):
"""The invalidation tx locktime equals the current block height."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
current_height = 750000
_, mock_from_io, _ = _run_invalidate(will, wallet, current_height=current_height)
# from_io(inputs, outputs, locktime=<height>, version=2)
_, kwargs = mock_from_io.call_args
assert kwargs["locktime"] == current_height
def test_basic_version_2(self):
"""The invalidation tx uses Bitcoin transaction version 2."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, _ = _run_invalidate(will, wallet)
_, kwargs = mock_from_io.call_args
assert kwargs["version"] == 2
def test_basic_output_value_deducts_fee(self):
"""The invalidation output value is balance minus fee.
Fee = estimated_size * fees_per_byte.
"""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
fees_per_byte = 10
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=fees_per_byte)
# The second call to from_address_and_value uses balance - fee.
# estimated_size returns 200, so fee = 200 * 10 = 2000.
# Expected output value = 1000000 - 2000 = 998000.
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_basic_spends_correct_utxos(self):
"""The invalidation tx spends the same UTXOs as the will."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, _ = _run_invalidate(will, wallet)
# First positional arg is the list of UTXOs to spend.
spent_utxos = mock_from_io.call_args[0][0]
assert len(spent_utxos) == 1
assert spent_utxos[0].prevout.to_str() == _PREVOUT_STR
def test_no_matching_utxos_returns_none(self):
"""When wallet UTXOs don't match any will inputs, returns None."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo(prevout_str="aaaa:1")])
result, _, _ = _run_invalidate(will, wallet)
assert result is None
def test_no_valid_items_returns_none(self):
"""When all will items are INVALIDATED, returns None."""
item = _make_willitem(valid=False)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate(will, wallet)
assert result is None
def test_empty_will_returns_none(self):
"""An empty will dictionary returns None."""
wallet = _mock_wallet([_make_utxo()])
result, _, _ = _run_invalidate({}, wallet)
assert result is None
def test_skips_young_coinbase(self):
"""Coinbase UTXOs younger than current_height + 100 are skipped."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
# Coinbase UTXO: block_height = 800050, current_height = 800000
# 800050 < 800000 + 100 => skipped
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
utxo.block_height = 800050
wallet = _mock_wallet([utxo])
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
assert result is None
def test_includes_mature_coinbase(self):
"""Coinbase UTXOs at or above current_height + 100 are included."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
utxo = _make_utxo(value_sats=1000000, is_coinbase=True)
utxo.block_height = 800150 # >= 800000 + 100
wallet = _mock_wallet([utxo])
result, _, _ = _run_invalidate(will, wallet, current_height=800000)
assert result is not None
def test_fee_exceeds_balance_returns_none(self):
"""When the fee exceeds the balance, returns None.
estimated_size (200) * fees_per_byte (100) = 20000 > balance (100).
"""
item = _make_willitem(value_sats=100)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
assert result is None
# from_io is still called once (for fee estimation), but the
# result is discarded because balance - fee <= 0.
assert mock_from_io.call_count == 1
def test_only_valid_items_contribute_balance(self):
"""INVALIDATED will items are excluded from the balance."""
valid_item = _make_willitem(value_sats=1000000, valid=True)
invalid_item = _make_willitem(value_sats=2000000, valid=False)
will = {"valid": valid_item, "invalid": invalid_item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# Balance = 1000000 (valid only), fee = 200 * 10 = 2000
# Output value = 998000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 998000
def test_first_from_io_uses_full_balance(self):
"""The first from_io call uses the full balance (before fee deduction)
to estimate the fee."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
# First from_address_and_value call: value = balance (1000000)
first_call_value = mock_out.call_args_list[0][0][1]
assert first_call_value == 1000000
def test_output_address_is_change_address(self):
"""The invalidation output goes to the wallet's change address."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, _, mock_out = _run_invalidate(will, wallet)
# Both calls to from_address_and_value use the change address.
for call in mock_out.call_args_list:
assert call[0][0] == _CHANGE_ADDR
def test_multiple_utxos_all_matched(self):
"""Multiple matching UTXOs are all included in the invalidation."""
item1 = _make_willitem(value_sats=500000)
item2 = _make_willitem(value_sats=300000)
will = {"tx1": item1, "tx2": item2}
# Two UTXOs with different prevouts matching the two will items.
# Since both items use the same _VALID_TX_HEX, their prevout is the
# same. To test multiple UTXOs, we need a second tx hex with a
# different input.
#
# However, get_all_inputs deduplicates by prevout_str, so even with
# two items sharing the same prevout, only one entry is added to
# prevout_to_spend. The first matching UTXO is what matters.
utxos = [_make_utxo()]
wallet = _mock_wallet(utxos)
result, mock_from_io, _ = _run_invalidate(will, wallet)
assert result is not None
# Only 1 UTXO spent (deduplication of shared prevout)
spent_utxos = mock_from_io.call_args[0][0]
assert len(spent_utxos) == 1
def test_zero_fees_per_byte(self):
"""With zero fee rate, the full balance goes to the output."""
item = _make_willitem(value_sats=1000000)
will = {"willtxid1": item}
wallet = _mock_wallet([_make_utxo()])
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=0)
assert mock_from_io.call_count == 2 # two calls (both succeed)
# Output value = balance - 0 = 1000000
second_call_value = mock_out.call_args_list[1][0][1]
assert second_call_value == 1000000
# ================================================================== #
# Will.set_invalidate - status flag cascade
# ================================================================== #
class TestSetInvalidate:
"""Tests for ``Will.set_invalidate()``: marking will items as invalidated."""
def test_single_item_no_children(self):
"""Invalidating a single will item sets INVALIDATED and clears VALID."""
item = _make_willitem(valid=True)
item.children = {}
will = {"willid1": item}
Will.set_invalidate("willid1", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_cascades_to_direct_children(self):
"""Invalidating a parent cascades INVALIDATED to its children."""
parent = _make_willitem(valid=True)
child = _make_willitem(valid=True)
parent.children = {"child_id": ["child_id", 0, 0]}
child.children = {}
will = {"parent_id": parent, "child_id": child}
Will.set_invalidate("parent_id", will)
assert parent.get_status("INVALIDATED") is True
assert parent.get_status("VALID") is False
assert child.get_status("INVALIDATED") is True
assert child.get_status("VALID") is False
def test_cascades_to_grandchildren(self):
"""Invalidating cascades through multiple levels of descendants."""
root = _make_willitem(valid=True)
branch = _make_willitem(valid=True)
leaf = _make_willitem(valid=True)
root.children = {"branch_id": ["branch_id", 0, 0]}
branch.children = {"leaf_id": ["leaf_id", 0, 0]}
leaf.children = {}
will = {
"root_id": root,
"branch_id": branch,
"leaf_id": leaf,
}
Will.set_invalidate("root_id", will)
for name, item in [("root", root), ("branch", branch), ("leaf", leaf)]:
assert item.get_status("INVALIDATED") is True, f"{name} should be INVALIDATED"
assert item.get_status("VALID") is False, f"{name} should not be VALID"
def test_empty_children_dict(self):
"""A will item with an empty children dict is a leaf (no cascade)."""
item = _make_willitem(valid=True)
item.children = {}
will = {"wid": item}
Will.set_invalidate("wid", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_does_not_affect_siblings(self):
"""Invalidating one item does not affect unrelated siblings."""
item_a = _make_willitem(valid=True)
item_b = _make_willitem(valid=True)
item_a.children = {}
item_b.children = {}
will = {"a": item_a, "b": item_b}
Will.set_invalidate("a", will)
assert item_a.get_status("INVALIDATED") is True
assert item_a.get_status("VALID") is False
assert item_b.get_status("INVALIDATED") is False
assert item_b.get_status("VALID") is True
def test_multiple_children(self):
"""Invalidating a parent with multiple children cascades to all of them."""
parent = _make_willitem(valid=True)
child1 = _make_willitem(valid=True)
child2 = _make_willitem(valid=True)
parent.children = {
"c1": ["c1", 0, 0],
"c2": ["c2", 0, 0],
}
child1.children = {}
child2.children = {}
will = {"p": parent, "c1": child1, "c2": child2}
Will.set_invalidate("p", will)
assert parent.get_status("INVALIDATED") is True
assert child1.get_status("INVALIDATED") is True
assert child2.get_status("INVALIDATED") is True
def test_idempotent(self):
"""Setting INVALIDATED twice on the same item is a safe no-op."""
item = _make_willitem(valid=True)
item.children = {}
will = {"wid": item}
Will.set_invalidate("wid", will)
Will.set_invalidate("wid", will)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All invalidation tests passed")

View File

@@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -65,6 +64,47 @@ def test_editable_dates_can_be_enabled():
assert BalConfig(cfg, "bal_editable_dates", False).get() is True assert BalConfig(cfg, "bal_editable_dates", False).get() is True
# ------------------------------------------------------------------ #
# History persistence settings (SAVE_HISTORY / HISTORY_LABEL)
# ------------------------------------------------------------------ #
def test_save_history_defaults_on():
"""History persistence is opt-out: the flag defaults to ON."""
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
assert save_history.get() is True
def test_save_history_can_be_disabled_and_read_back():
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
save_history.set(False)
assert BalConfig(cfg, "bal_save_history", True).get() is False
def test_history_label_default_template():
"""The default label contains the {willexecutor} variable."""
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert "{willexecutor}" in history_label.get()
def test_history_label_can_be_changed_and_read_back():
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
history_label.set("My custom label for {willexecutor}")
assert BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
).get() == "My custom label for {willexecutor}"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# C4b - Reset to defaults # C4b - Reset to defaults
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -83,9 +123,10 @@ def _reset_to_defaults(configs):
def test_reset_restores_all_dialog_settings(): def test_reset_restores_all_dialog_settings():
"""C4b: Reset restores every dialog setting to its factory default. """C4b: Reset restores every dialog setting to its factory default.
The dialog exposes seven settings: the original six plus the Group C The dialog exposes nine settings: the original six, the Group C
"Editable dates" checkbox, which the Reset button must also restore (this "Editable dates" checkbox, and the Group H "Save inheritance transactions
was a follow-up fix after the first test round). in history" checkbox + "History label" field, which the Reset button must
also restore.
""" """
cfg = FakeConfig() cfg = FakeConfig()
@@ -103,6 +144,10 @@ def test_reset_restores_all_dialog_settings():
"bal_event_description", "bal_event_description",
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete", "BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
) )
save_history = BalConfig(cfg, "bal_save_history", True)
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
settings = [ settings = [
hide_replaced, hide_replaced,
hide_invalidated, hide_invalidated,
@@ -111,6 +156,8 @@ def test_reset_restores_all_dialog_settings():
calendar_app, calendar_app,
event_summary, event_summary,
event_description, event_description,
save_history,
history_label,
] ]
# Mutate every setting away from its default. # Mutate every setting away from its default.
@@ -121,11 +168,15 @@ def test_reset_restores_all_dialog_settings():
calendar_app.set("/custom/app") calendar_app.set("/custom/app")
event_summary.set("custom summary") event_summary.set("custom summary")
event_description.set("custom description") event_description.set("custom description")
save_history.set(False)
history_label.set("custom label")
# Sanity: the values really changed. # Sanity: the values really changed.
assert hide_replaced.get() is False assert hide_replaced.get() is False
assert editable_dates.get() is True assert editable_dates.get() is True
assert calendar_app.get() == "/custom/app" assert calendar_app.get() == "/custom/app"
assert save_history.get() is False
assert history_label.get() == "custom label"
# Reset and verify each one is back to its declared default. # Reset and verify each one is back to its declared default.
_reset_to_defaults(settings) _reset_to_defaults(settings)
@@ -133,6 +184,11 @@ def test_reset_restores_all_dialog_settings():
assert s.get() == s.default assert s.get() == s.default
# In particular the "Editable dates" flag is back OFF. # In particular the "Editable dates" flag is back OFF.
assert editable_dates.get() is False assert editable_dates.get() is False
# And the history persistence flag is back ON with the default template.
assert save_history.get() is True
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
def test_reset_does_not_touch_unrelated_settings(): def test_reset_does_not_touch_unrelated_settings():

View File

@@ -10,13 +10,12 @@ Covered behaviour:
most one reminder per available day, and never returns more reminders than most one reminder per available day, and never returns more reminders than
requested. requested.
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports ``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like so these tests run without Qt (or Electrum) at all.
the other GUI tests.
Run: Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ source /home/steal/devel/bal/electrum/env/bin/activate
python3 -m pytest tests/test_group_d_alarms.py -q python3 tests/test_group_d_alarms.py
""" """
import os import os
@@ -25,8 +24,7 @@ import sys
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))
from bal.core.plugin_base import BalConfig from bal.core.plugin_base import BalConfig
from bal.gui.qt.widgets import compute_reminder_offsets from bal.core.reminders import compute_reminder_offsets
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Mocks # Mocks

View File

@@ -0,0 +1,397 @@
"""
Group E - karen7 wallet: build the inheritance then generate the
cancellation (invalidation) transaction.
This test exercises the full pipeline with REAL Electrum transaction
building (no mocking of from_io, from_address_and_value, or is_address):
1. Load the karen7 regtest wallet (heirs + UTXOs).
2. Set Electrum to regtest mode so bcrt1q addresses validate.
3. Build the inheritance transactions via ``Heirs.buildTransactions``
using real ``PartialTransaction.from_io`` and real
``PartialTxOutput.from_address_and_value``.
4. Wrap each built transaction into a ``WillItem`` with VALID status.
5. Populate ``_trusted_value_sats`` on each input (what
``add_info_from_wallet`` does in the real flow).
6. Call ``Will.invalidate_will()`` to generate the cancellation tx.
7. Assert that the cancellation tx is well-formed.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
"""
import copy
import json
import os
import sys
import pytest # pyright: ignore[reportMissingImports]
# ------------------------------------------------------------------ #
# Electrum regtest mode (replaces mocking bitcoin.is_address)
# ------------------------------------------------------------------ #
from electrum import constants
constants.net = constants.BitcoinRegtest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import bitcoin
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
TxOutpoint,
)
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.will import Will, WillItem
# ------------------------------------------------------------------ #
# Load karen7 wallet data
# ------------------------------------------------------------------ #
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
with open(_WALLET_PATH) as _f:
_KAREN7_DATA = json.load(_f)
# ------------------------------------------------------------------ #
# Minimal real implementations (no MagicMock)
# ------------------------------------------------------------------ #
class _Karen7Wallet:
"""Minimal wallet implementation for tests.
Provides only the methods that ``buildTransactions`` and
``invalidate_will`` call. ``network`` is ``None`` so
``Util.get_current_height`` returns 0 without network access.
"""
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
def __init__(self, utxos):
self._utxos = utxos
self.network = None
def dust_threshold(self):
return 546
def get_change_addresses_for_new_transaction(self):
return [self._CHANGE_ADDR]
def get_utxos(self):
return self._utxos
class _Karen7BalPlugin:
"""Minimal bal_plugin config for tests.
Provides only the config accessors that ``buildTransactions`` reads.
No will-executors (``NO_WILLEXECUTOR = True``).
"""
class _NoWillexecutor:
def get(self, *a, **kw):
return True
class _MaxFee:
def get(self, *a, **kw):
return 500000
class _EmptyWelist:
default = {}
def get(self, *a, **kw):
return {"regtest": {}}
NO_WILLEXECUTOR = _NoWillexecutor()
MAX_WILLEXECUTOR_FEE = _MaxFee()
WILLEXECUTORS = _EmptyWelist()
def get_decimal_point(self):
return 8
# ------------------------------------------------------------------ #
# UTXO builder from karen7 data (real PartialTxInput objects)
# ------------------------------------------------------------------ #
def _build_real_utxos(data):
"""Build real ``PartialTxInput`` objects from the karen7 wallet JSON.
Each UTXO gets a proper ``scriptpubkey`` so that ``is_segwit()``
returns ``True`` and the resulting ``PartialTransaction`` can
compute a real ``txid()``.
"""
utxos = []
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
prevout = TxOutpoint(
txid=bfh(txid), out_idx=int(idx)
)
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
txin._TxInput__address = addr
txin._TxInput__scriptpubkey = bitcoin.address_to_script(
addr
)
txin.is_mine = True
utxos.append(txin)
return utxos
# ------------------------------------------------------------------ #
# Build karen7 UTXO value lookup (for populating tx inputs)
# ------------------------------------------------------------------ #
def _build_utxo_value_map(data):
"""Return ``{prevout_str: value_sats}`` from karen7 wallet data."""
m = {}
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for _, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
m[f"{txid}:{idx}"] = value
return m
# ------------------------------------------------------------------ #
# Populate _trusted_value_sats on WillItem tx inputs
# ------------------------------------------------------------------ #
def _populate_input_values(will, utxo_value_map):
"""Set ``_trusted_value_sats`` on every input of every will tx.
This is the equivalent of what ``add_info_from_wallet`` does in the
real flow: looking up the UTXO value and attaching it to the input.
"""
for _, wi in will.items():
for txin in wi.tx.inputs():
prevout_str = txin.prevout.to_str()
if txin._trusted_value_sats is None and prevout_str in utxo_value_map:
txin._trusted_value_sats = utxo_value_map[prevout_str]
# ------------------------------------------------------------------ #
# Inheritance builder (real Electrum, no mocking)
# ------------------------------------------------------------------ #
def _build_inheritance(utxos):
"""Build the inheritance transactions from karen7's heirs and UTXOs.
Returns ``(txs, heirs_model)`` where ``txs`` is a dict of real
``PartialTransaction`` objects produced by ``Heirs.buildTransactions``.
"""
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
wallet = _Karen7Wallet(utxos)
bal_plugin = _Karen7BalPlugin()
txs = h.buildTransactions(bal_plugin, wallet, tx_fees=1, utxos=utxos)
return txs or {}, h
def _txs_to_will(txs, heirs_data):
"""Convert built transactions into a ``{txid: WillItem}`` will dict
with VALID status, using karen7's heir data."""
will = {}
for txid, tx in txs.items():
item_dict = {
"tx": tx,
"heirs": copy.deepcopy(heirs_data),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 1,
}
wi = WillItem(item_dict, _id=txid)
will[txid] = wi
return will
# ================================================================== #
# Build and invalidate tests
# ================================================================== #
class TestKaren7BuildAndInvalidate:
"""Load the real karen7 regtest wallet, build the inheritance
transactions with real Electrum, then generate the cancellation
(invalidation) transaction."""
@pytest.fixture(autouse=True)
def _setup(self):
"""Shared setup: build UTXOs, inheritance, and will once."""
self.utxos = _build_real_utxos(_KAREN7_DATA)
self.utxo_value_map = _build_utxo_value_map(_KAREN7_DATA)
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
self.heirs_data = _KAREN7_DATA["heirs"]
self.txs, self.heirs_model = _build_inheritance(self.utxos)
self.wallet = _Karen7Wallet(self.utxos)
self.will = _txs_to_will(self.txs, self.heirs_data)
_populate_input_values(self.will, self.utxo_value_map)
# ------------------------------------------------------------------ #
# Build tests
# ------------------------------------------------------------------ #
def test_build_produces_real_partial_transactions(self):
"""Building the inheritance produces real PartialTransaction objects."""
assert self.txs, "buildTransactions returned empty"
for txid, tx in self.txs.items():
assert isinstance(tx, PartialTransaction), (
f"tx {txid} should be a real PartialTransaction, "
f"got {type(tx).__name__}"
)
def test_built_txs_have_valid_txid(self):
"""Every built transaction has a computable txid (not None)."""
assert self.txs, "no transactions built"
for txid, tx in self.txs.items():
computed = tx.txid()
assert computed is not None, (
f"tx {txid} has txid() == None"
)
assert computed == txid, (
f"txid mismatch: key={txid}, computed={computed}"
)
def test_built_tx_has_karen7_heirs(self):
"""The built will contains karen7's four heirs."""
assert len(self.heirs_model) == 4
assert list(self.heirs_model.keys()) == [
"aaaa", "lucia", "mario", "mario2"
]
def test_will_items_are_valid(self):
"""Every WillItem in the will starts with VALID=True."""
assert self.will, "will is empty"
for wid, wi in self.will.items():
assert wi.get_status("VALID") is True, (
f"WillItem {wid} should be VALID"
)
def test_will_inputs_have_values(self):
"""After populating, every tx input has a non-None value_sats."""
for wid, wi in self.will.items():
for i, txin in enumerate(wi.tx.inputs()):
assert txin.value_sats() is not None, (
f"WillItem {wid} input {i} "
f"({txin.prevout.to_str()}) has no value"
)
# ------------------------------------------------------------------ #
# Invalidation tests
# ------------------------------------------------------------------ #
def test_invalidate_returns_real_tx(self):
"""Calling invalidate_will produces a real PartialTransaction."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None, "invalidate_will returned None"
assert isinstance(result, PartialTransaction), (
f"expected PartialTransaction, got {type(result).__name__}"
)
def test_invalidation_tx_has_rbf(self):
"""The cancellation tx has RBF enabled."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.is_rbf_enabled() is True
def test_invalidation_tx_locktime(self):
"""The cancellation tx locktime equals the current height.
With ``network=None`` the current height is 0.
"""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.locktime == 0
def test_invalidation_tx_version_2(self):
"""The cancellation tx uses Bitcoin transaction version 2."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
assert result.version == 2
def test_invalidation_spends_correct_utxos(self):
"""The cancellation tx spends the same UTXOs as the will."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
will_prevouts = set()
for wi in self.will.values():
for txin in wi.tx.inputs():
will_prevouts.add(txin.prevout.to_str())
for txin in result.inputs():
assert txin.prevout.to_str() in will_prevouts, (
f"inval input {txin.prevout.to_str()} not in will UTXOs"
)
def test_invalidation_output_to_change_address(self):
"""The cancellation output goes to the wallet's change address."""
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is not None
outputs = result.outputs()
assert len(outputs) == 1
assert outputs[0].address == _Karen7Wallet._CHANGE_ADDR
def test_invalidation_output_value_deducts_fee(self):
"""The output value equals balance minus estimated fee.
balance = sum of input values (from will inputs).
fee = estimated_size * fees_per_byte.
"""
fees_per_byte = 10
result = Will.invalidate_will(
self.will, self.wallet, fees_per_byte
)
assert result is not None
balance = sum(txin.value_sats() for txin in result.inputs()
if txin.value_sats() is not None)
fee = result.estimated_size() * fees_per_byte
expected = balance - fee
assert result.outputs()[0].value == expected, (
f"output value {result.outputs()[0].value} != "
f"expected {expected} (balance={balance}, fee={fee})"
)
def test_all_invalidated_returns_none(self):
"""When all will items are INVALIDATED, returns None."""
for wid in self.will:
self.will[wid].set_status("INVALIDATED", True)
result = Will.invalidate_will(self.will, self.wallet, 10)
assert result is None
def test_empty_will_returns_none(self):
"""An empty will dictionary returns None."""
result = Will.invalidate_will({}, self.wallet, 10)
assert result is None

View File

@@ -30,18 +30,21 @@ import time
import traceback import traceback
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest # pyright: ignore[reportMissingImports]
# Make the plugin package importable when run directly (tests/ is one level # Make the plugin package importable when run directly (tests/ is one level
# below the repo root that contains the ``bal`` package). # below the repo root that contains the ``bal`` package).
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))
from bal.core.will import WillItem, Will, HeirNotFoundException
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.reminders import (
compute_reminder_offsets,
format_time,
ical_escape,
write_temp_ics,
)
from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors from bal.core.willexecutors import Willexecutors
from bal.gui.qt.calendar import BalCalendar
from bal.gui.qt.widgets import compute_reminder_offsets
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused from test_core_will.py so WillItem can parse a real tx. # version 2). Reused from test_core_will.py so WillItem can parse a real tx.
@@ -197,8 +200,8 @@ def test_e1_build_separate_events_for_karen7():
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"] lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
for idx, offset in enumerate(offsets, start=1): for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset)) event_dt = format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})") summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([ lines.extend([
"BEGIN:VEVENT", "BEGIN:VEVENT",
f"UID:bal-{wallet}-{offset}d", f"UID:bal-{wallet}-{offset}d",
@@ -220,7 +223,7 @@ def test_e1_build_separate_events_for_karen7():
assert any("(reminder 1/3)" in ln for ln in lines) assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines) assert any("(reminder 3/3)" in ln for ln in lines)
# The LAST event (offset 1) sits one day before the locktime. # The LAST event (offset 1) sits one day before the locktime.
last_dt = BalCalendar.format_time(locktime - timedelta(days=1)) last_dt = format_time(locktime - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines assert f"DTSTART:{last_dt}" in lines
@@ -228,7 +231,7 @@ def test_e1_event_description_escaping():
"""Special iCalendar characters in karen7's event text are escaped so """Special iCalendar characters in karen7's event text are escaped so
the .ics file stays valid.""" the .ics file stays valid."""
raw = "Wallet karen7; heirs: alice, bob" raw = "Wallet karen7; heirs: alice, bob"
escaped = BalCalendar.ical_escape(raw) escaped = ical_escape(raw)
assert "\\;" in escaped # semicolon escaped assert "\\;" in escaped # semicolon escaped
assert "\\," in escaped # comma escaped assert "\\," in escaped # comma escaped
assert "karen7" in escaped assert "karen7" in escaped
@@ -246,7 +249,7 @@ def test_e1_write_temp_ics_for_karen7():
"END:VEVENT\r\n" "END:VEVENT\r\n"
"END:VCALENDAR\r\n" "END:VCALENDAR\r\n"
) )
path = BalCalendar.write_temp_ics(content) path = write_temp_ics(content)
try: try:
assert os.path.isfile(path) assert os.path.isfile(path)
with open(path, "rb") as f: with open(path, "rb") as f:
@@ -275,7 +278,7 @@ def test_e2_karen7_add_remove_heir():
heirs["charlie"] = ["addr_charlie", "20000", "30d"] heirs["charlie"] = ["addr_charlie", "20000", "30d"]
assert "charlie" in heirs assert "charlie" in heirs
assert "charlie" in wallet.db.get("heirs", {}) assert "charlie" in (wallet.db.get("heirs") or {})
removed = heirs.pop("alice") removed = heirs.pop("alice")
assert removed is not None assert removed is not None
@@ -550,7 +553,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
for txid, outputs in txo.items(): for txid, outputs in txo.items():
if not isinstance(outputs, dict): if not isinstance(outputs, dict):
continue continue
for addr, out_map in outputs.items(): for _, out_map in outputs.items():
if not isinstance(out_map, dict): if not isinstance(out_map, dict):
continue continue
for idx, info in out_map.items(): for idx, info in out_map.items():
@@ -623,6 +626,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
"bal.core.heirs.PartialTransaction.from_io", "bal.core.heirs.PartialTransaction.from_io",
side_effect=_fake_from_io, side_effect=_fake_from_io,
): ):
result = None
try: try:
result = h.buildTransactions( result = h.buildTransactions(
bal_plugin, wallet, tx_fees=1, utxos=utxos bal_plugin, wallet, tx_fees=1, utxos=utxos

View File

@@ -16,8 +16,9 @@ Run:
python3 tests/test_group_f_heir_change_rebuild.py python3 tests/test_group_f_heir_change_rebuild.py
""" """
import sys
import os import os
import sys
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))
from bal.core.will import Will from bal.core.will import Will
@@ -112,5 +113,5 @@ def test_same_heirs_empty():
if __name__ == "__main__": if __name__ == "__main__":
import pytest import pytest # pyright: ignore[reportMissingImports]
raise SystemExit(pytest.main([__file__, "-v"])) raise SystemExit(pytest.main([__file__, "-v"]))

View File

@@ -10,13 +10,12 @@ that would fall in the past.
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
drives that decision. drives that decision.
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports ``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like so these tests run without Qt (or Electrum) at all.
the other GUI tests.
Run: Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \ source /home/steal/devel/bal/electrum/env/bin/activate
python3 -m pytest tests/test_group_g_basic_calendar.py -q python3 tests/test_group_g_basic_calendar.py
""" """
import os import os
@@ -24,8 +23,7 @@ import sys
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))
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS, from bal.core.reminders import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
basic_reminder_offsets)
def test_basic_offsets_all_future(): def test_basic_offsets_all_future():

View File

@@ -3,117 +3,99 @@ Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
Background (reported by the owner): with the plugin left in BASIC mode (the Background (reported by the owner): with the plugin left in BASIC mode (the
default), the "Check Alive" (threshold) field is hidden from the user and default), the "Check Alive" (threshold) field is hidden from the user and
stays stuck at its old/default value (roughly "today + 11 months", i.e. one stays stuck at its old/default value. If the user then anticipates the
year minus the default 30-day margin). If the user then anticipates the delivery time (locktime) to something earlier than that stale threshold, the
delivery time (locktime) to something earlier than that stale threshold - e.g. checks that compare locktime against ``date_to_check`` would incorrectly treat
"in 1 month" - two different checks that compare locktime against the will as "expired"/"invalid", even though the whole Check Alive concept is
``date_to_check`` (the resolved threshold) would incorrectly treat the will as supposed to be inert in BASIC mode.
"expired"/"invalid", even though the whole Check Alive concept is supposed to
be inert in BASIC mode.
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track The real fix lives in ``BalWindow.init_class_variables`` (window.py), which
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it, sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
so it is always < locktime by construction. In ADVANCED mode nothing changes: pure policy in ``bal.core.checkalive``:
the stored threshold is used as-is.
These tests call the real production method directly (no GUI/Electrum wallet * ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
needed - it is a plain ``@staticmethod``), so they exercise the exact code reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
used at runtime rather than a re-implementation. * ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
Check-Alive guard should fire (never in BASIC).
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
needed, and no Qt import).
Run: Run:
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_group_i_basic_checkalive.py
""" """
import os
import sys import sys
import time import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above) from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS resolve_date_to_check,
def test_offset_is_two_hours():
"""Owner-approved margin: exactly 2 hours."""
assert OFFSET == 2 * 60 * 60
def _relative_days_to_midnight_timestamp(days):
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N
days", truncated to midnight. Used to compute the expected value in
tests without duplicating the parsing logic itself."""
from datetime import datetime, timedelta
now = datetime.now()
return (
(now + timedelta(days=days))
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
) )
def test_basic_mode_relative_locktime_one_year(): def test_basic_mode_resolves_to_now():
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier.""" """BASIC mode: date_to_check is "now", never the stale stored threshold."""
result = BalWindow.compute_date_to_check(True, "1y", "30d") fake_now = 1_800_000_000.0
expected_locktime = _relative_days_to_midnight_timestamp(365) result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
assert abs(result - (expected_locktime - OFFSET)) < 5 assert result == fake_now
def test_basic_mode_anticipated_delivery_stays_consistent(): def test_basic_mode_ignores_stored_threshold():
"""BASIC + delivery anticipated to 1 month: date_to_check follows it, """BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
NOT the old ~11-month threshold - this is the exact bug scenario does NOT jump to the old ~11-month threshold - this is the exact bug
reported by the owner.""" scenario reported by the owner."""
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
anticipated_locktime = "30d" # user moved delivery to ~1 month from now fake_now = 1_800_000_000.0
result = BalWindow.compute_date_to_check( result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
True, anticipated_locktime, stale_threshold assert result == fake_now
)
locktime_ts = _relative_days_to_midnight_timestamp(30)
# date_to_check must be (delivery - 2h), always strictly before delivery.
assert result < locktime_ts
assert abs((locktime_ts - OFFSET) - result) < 5
def test_basic_mode_date_to_check_always_before_locktime(): def test_advanced_mode_uses_stored_threshold():
"""Regression guard for the reported bug: whatever the delivery date is """ADVANCED mode: behaviour stays exactly as before - the stored threshold
(even very close to "now"), date_to_check must stay before it.""" is used as-is, regardless of the locktime value."""
for relative_locktime in ("1d", "7d", "30d", "90d", "365d"):
result = BalWindow.compute_date_to_check(True, relative_locktime, "30d")
parsed_locktime = _relative_days_to_midnight_timestamp(
int(relative_locktime[:-1])
)
assert result < parsed_locktime, (
f"date_to_check ({result}) should be before locktime "
f"({parsed_locktime}) for locktime={relative_locktime}"
)
def test_advanced_mode_uses_stored_threshold_unchanged():
"""ADVANCED mode: behaviour must stay exactly as before this fix - the
stored threshold is used as-is, regardless of the locktime value."""
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
result = BalWindow.compute_date_to_check(False, "30d", absolute_threshold) result = resolve_date_to_check(False, {"threshold": absolute_threshold})
assert abs(result - absolute_threshold) < 1 assert abs(result - absolute_threshold) < 1
def test_basic_mode_falls_back_on_unparsable_locktime(): def test_advanced_mode_parses_relative_threshold():
"""If the locktime can't be parsed for any reason, BASIC mode must not """ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
crash: it falls back to the stored threshold, same as ADVANCED.""" result = resolve_date_to_check(False, {"threshold": "30d"})
absolute_threshold = time.time() + 5 * 86400 assert result > time.time()
result = BalWindow.compute_date_to_check(
True, {"not": "a valid locktime"}, absolute_threshold
) def test_basic_mode_never_expired():
assert abs(result - absolute_threshold) < 1 """BASIC mode can never be "expired": a passed check-alive date must never
force a postpone/rewrite of the will."""
assert check_alive_expired(True, time.time() - 10_000) is False
assert check_alive_expired(True, 1_000_000_000.0) is False
def test_advanced_mode_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_mode_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
def test_basic_mode_never_raises_check_alive_error():
"""Regression guard for the reported bug: whatever the delivery date,
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
assert not check_alive_expired(True, date_to_check)
# If it did fire, this is the exact exception that would be raised.
assert issubclass(CheckAliveError, Exception)
if __name__ == "__main__": if __name__ == "__main__":
test_offset_is_two_hours() for name in sorted(dir()):
test_basic_mode_relative_locktime_one_year() if name.startswith("test_"):
test_basic_mode_anticipated_delivery_stays_consistent() globals()[name]()
test_basic_mode_date_to_check_always_before_locktime() print(f" [OK] {name}")
test_advanced_mode_uses_stored_threshold_unchanged() print("[OK] All test_group_i_basic_checkalive tests passed.")
test_basic_mode_falls_back_on_unparsable_locktime()
print("All test_group_i_basic_checkalive tests passed.")

View File

@@ -1,128 +1,25 @@
""" """
Tests for ``bal.gui.qt.calendar``. Tests for ``bal.gui.qt.calendar``.
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line, Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
write_temp_ics, open_with_default_app. The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
""" """
import os
import sys import sys
import tempfile
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.calendar import BalCalendar from bal.gui.qt.calendar import BalCalendar
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert BalCalendar.format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
from datetime import timedelta
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
result = BalCalendar.format_time(dt)
assert result.endswith("Z")
assert result == "20250115T060000Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
text = "hello world"
assert BalCalendar.ical_escape(text) == "hello world"
def test_ical_escape_backslash():
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert BalCalendar.ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert BalCalendar.ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\r\nline2"
result = BalCalendar.ical_escape(text)
assert "\r\n" in result
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
text = "\\;,"
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
line = "SUMMARY:Test"
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "X-LONG:" + "a" * 100
result = BalCalendar.fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert len(parts) > 1
assert result.startswith("X-LONG:")
def test_fold_ical_line_unicode():
line = "DESCRIPTION:" + "\u20ac" * 40
result = BalCalendar.fold_ical_line(line, limit=75)
assert "\r\n " in result
assert "\u20ac" in result
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = BalCalendar.write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = BalCalendar.write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# open_with_default_app # open_with_default_app
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_open_with_default_app_not_found(): def test_open_with_default_app_not_found():
result = BalCalendar.open_with_default_app( result = BalCalendar.open_with_default_app(
"/nonexistent/calendar_app", "/tmp/fake.ics" "/nonexistent/calendar_app", "/tmp/fake.ics"

View File

@@ -1,19 +1,21 @@
""" """
Tests for ``bal.gui.qt.common``. Tests for ``bal.gui.qt.common``.
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui. Covers shown_cv, add_widget, log_error, export_meta_gui (and the
CheckAliveError exception, which now lives in ``bal.core.checkalive``).
Run: Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel
# Import the module itself, not via "from .common import *" # Import the module itself, not via "from .common import *"
import bal.gui.qt.common as C import bal.gui.qt.common as common
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
@@ -23,18 +25,18 @@ _app = QApplication.instance() or QApplication(sys.argv)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_shown_cv_default(): def test_shown_cv_default():
cv = C.shown_cv(True) cv = common.shown_cv(True)
assert cv.get() is True assert cv.get() is True
def test_shown_cv_set(): def test_shown_cv_set():
cv = C.shown_cv(True) cv = common.shown_cv(True)
cv.set(False) cv.set(False)
assert cv.get() is False assert cv.get() is False
def test_shown_cv_roundtrip(): def test_shown_cv_roundtrip():
cv = C.shown_cv(False) cv = common.shown_cv(False)
assert cv.get() is False assert cv.get() is False
cv.set(True) cv.set(True)
assert cv.get() is True assert cv.get() is True
@@ -43,23 +45,30 @@ def test_shown_cv_roundtrip():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# CheckAliveError # CheckAliveError (moved to bal.core.checkalive; kept tested via the
# common->core import chain)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_check_alive_error_default(): def test_check_alive_error_default():
err = C.CheckAliveError(1000000) from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000 assert err.timestamp_to_check == 1000000
def test_check_alive_error_str(): def test_check_alive_error_str():
err = C.CheckAliveError(1000000) from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
s = str(err) s = str(err)
assert "Check alive expired" in s assert "Check alive expired" in s
assert "1970" in s assert "1970" in s
def test_check_alive_error_subclass(): def test_check_alive_error_subclass():
assert issubclass(C.CheckAliveError, Exception) from bal.core.checkalive import CheckAliveError
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -68,17 +77,15 @@ def test_check_alive_error_subclass():
def test_add_widget(): def test_add_widget():
grid = QGridLayout() grid = QGridLayout()
parent = QWidget()
label = QLabel("test") label = QLabel("test")
C.add_widget(grid, "Label", label, 0, "Help text") common.add_widget(grid, "Label", label, 0, "Help text")
assert grid.count() == 3 # label + widget + help button assert grid.count() == 3 # label + widget + help button
def test_add_widget_multiple_rows(): def test_add_widget_multiple_rows():
grid = QGridLayout() grid = QGridLayout()
parent = QWidget() common.add_widget(grid, "A", QLabel("a"), 0, "help_a")
C.add_widget(grid, "A", QLabel("a"), 0, "help_a") common.add_widget(grid, "B", QLabel("b"), 1, "help_b")
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
assert grid.count() == 6 assert grid.count() == 6
@@ -87,7 +94,7 @@ def test_add_widget_multiple_rows():
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def test_log_error_no_window(): def test_log_error_no_window():
C.log_error((Exception, Exception("test"), None)) common.log_error((Exception, Exception("test"), None))
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -0,0 +1,216 @@
"""
Tests for the history-save hook in ``BalWindow``.
Verifies that every successful prepare (including the "Prepare" menu action)
persists the freshly prepared transactions into the wallet's local history,
while abort paths (which return ``None``) skip the save. Also verifies that
after any local-history change (saving or removing will transactions) the
wallet tabs are re-rendered through ``update_tabs`` (all tabs) plus
``update_status`` (status-bar balance), and that the rebuild path uses the same
full refresh.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_prepare_will_history.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import Mock, patch
import bal.gui.qt.window as win_mod
from bal.core.util import Util
from bal.core.will import NotCompleteWillException, Will
from bal.gui.qt.window import BalWindow
# ------------------------------------------------------------------ #
# prepare_will -> _save_will_to_history
# ------------------------------------------------------------------ #
def test_prepare_will_saves_to_history_on_success():
win = object.__new__(BalWindow)
will = {"wid": object()}
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=will)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is will
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_called_once_with()
def test_prepare_will_skips_save_when_build_aborted():
win = object.__new__(BalWindow)
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=None)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is None
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_not_called()
# ------------------------------------------------------------------ #
# history refresh: _save_will_to_history -> update_tabs + update_status
# ------------------------------------------------------------------ #
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class _CfgBag:
def __init__(self, **kwargs):
for name, value in kwargs.items():
setattr(self, name, value)
class _Wallet:
def dust_threshold(self):
return 546
class _FakeWindow:
def __init__(self, *, with_update_tabs=True):
self.show_message = Mock()
self.update_status = Mock()
self.history_list = Mock()
self.history_list.update = Mock()
if with_update_tabs:
self.update_tabs = Mock()
class _FakeQTimer:
calls = []
@classmethod
def singleShot(cls, delay, callable_):
cls.calls.append((delay, callable_))
def _make_save_window(save_enabled):
win = object.__new__(BalWindow)
win.bal_plugin = _CfgBag(
SAVE_HISTORY=_Cfg(save_enabled), HISTORY_LABEL=_Cfg("LBL")
)
win.willitems = {"wid": object()}
win.wallet = object()
return win
def test_save_will_to_history_schedules_refresh_when_enabled():
win = _make_save_window(save_enabled=True)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_called_once_with(win.willitems, win.wallet, "LBL")
schedule_mock.assert_called_once_with()
def test_save_will_to_history_skips_save_and_refresh_when_disabled():
win = _make_save_window(save_enabled=False)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_not_called()
schedule_mock.assert_not_called()
def test_save_will_to_history_schedules_refresh_even_on_error():
win = _make_save_window(save_enabled=True)
with (
patch.object(
Will,
"save_valid_transactions_to_history",
side_effect=RuntimeError("boom"),
),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
schedule_mock.assert_called_once_with()
def test_schedule_history_refresh_marshals_to_gui_thread():
win = object.__new__(BalWindow)
with patch.object(win_mod, "QTimer", _FakeQTimer):
_FakeQTimer.calls.clear()
BalWindow._schedule_history_refresh(win)
assert _FakeQTimer.calls == [(0, win._refresh_after_history_save)]
def test_refresh_after_history_save_calls_update_tabs_and_status():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=True)
BalWindow._refresh_after_history_save(win)
win.window.update_tabs.assert_called_once_with()
win.window.update_status.assert_called_once_with()
win.window.history_list.update.assert_not_called()
def test_refresh_after_history_save_falls_back_to_history_list():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=False)
BalWindow._refresh_after_history_save(win)
win.window.history_list.update.assert_called_once_with()
win.window.update_status.assert_called_once_with()
def test_rebuild_path_schedules_full_refresh():
win = object.__new__(BalWindow)
win.disable_plugin = False
win.heirs = {"h": object()}
win.willexecutors = {}
win.no_willexecutor = True
win.willitems = {}
win.will = {}
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
)
win.window = _FakeWindow()
win.window.wallet = _Wallet()
with (
patch.object(Util, "get_available_utxos", return_value=[]),
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
patch.object(Will, "get_min_locktime", return_value=0),
patch.object(Will, "check_amounts"),
patch.object(BalWindow, "init_class_variables"),
patch.object(BalWindow, "build_will"),
patch.object(
BalWindow,
"check_will",
side_effect=[NotCompleteWillException(), None],
),
patch.object(BalWindow, "update_all"),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow.build_inheritance_transaction(win)
schedule_mock.assert_called_once_with()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All prepare-will history tests passed")

View File

@@ -8,16 +8,19 @@ Run:
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.theme import status_color from bal.gui.qt.theme import signature_suffix, status_color
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Helpers # Helpers
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
class FakeWillItem: class FakeWillItem:
sigs_required = 0
sigs_have = 0
def __init__(self, **status_flags): def __init__(self, **status_flags):
self._status = dict(status_flags) self._status = dict(status_flags)
def get_status(self, name): def get_status(self, name):
@@ -97,6 +100,54 @@ def test_color_check_fail_overrides_push_fail():
assert status_color(item) == "#e83845" assert status_color(item) == "#e83845"
# ------------------------------------------------------------------ #
# PARTIALLY_SIGNED
# ------------------------------------------------------------------ #
def test_color_partially_signed():
assert status_color(FakeWillItem(PARTIALLY_SIGNED=True)) == "#ffb347"
def test_color_partially_signed_overridden_by_higher_priority():
item = FakeWillItem(PARTIALLY_SIGNED=True, PUSHED=True)
assert status_color(item) == "#73f3c8"
# ------------------------------------------------------------------ #
# signature_suffix
# ------------------------------------------------------------------ #
def test_signature_suffix_partial():
item = FakeWillItem(PARTIALLY_SIGNED=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == " (1/2)"
def test_signature_suffix_new():
item = FakeWillItem()
item.sigs_required = 2
item.sigs_have = 0
assert signature_suffix(item) == " (0/2)"
def test_signature_suffix_complete_empty():
item = FakeWillItem(COMPLETE=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_unknown_required_empty():
item = FakeWillItem()
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_missing_fields_empty():
assert signature_suffix(FakeWillItem()) == ""
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Main # Main
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #

View File

@@ -13,13 +13,11 @@ Run:
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QWidget from PyQt6.QtWidgets import QApplication, QWidget
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)
@@ -120,23 +118,25 @@ 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 # replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped # block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now). # anymore (locktimes are always UNIX timestamps now). The helper moved to
from bal.gui.qt.widgets import LockTimeRawEdit # bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
assert LockTimeRawEdit.replace_str("123d") == "123" from bal.core.input_rules import replace_dy_suffixes
assert LockTimeRawEdit.replace_str("456y") == "456" assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix) # "b" is left untouched (no longer a recognised suffix)
assert LockTimeRawEdit.replace_str("789b") == "789b" assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains # only d/y are stripped; a stray "b" remains
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b" assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_locktime_raw_edit_checkbdy(): def test_locktime_raw_edit_checkbdy():
from bal.gui.qt.widgets import LockTimeRawEdit # checkbdy moved to bal.core.input_rules (_checkbdy).
from bal.core.input_rules import _checkbdy
# character at expected position matches appendix # character at expected position matches appendix
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d") pos, s = _checkbdy("123d", 4, "d")
assert s == "123d" assert s == "123d"
# character at expected position does not match # character at expected position does not match
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d") pos, s = _checkbdy("123x", 4, "d")
assert s == "123x" assert s == "123x"

150
tests/test_gui_will_menu.py Normal file
View File

@@ -0,0 +1,150 @@
"""
Tests for the Will-tab context menu and Will-Executor bulk-selection helpers
in ``bal.gui.qt.lists``.
Covers ``_can_sign`` / ``_can_broadcast`` / ``_can_delete`` and
``_apply_select_all`` (the pure logic behind the new context-menu actions and
the "Select All" dropdown).
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_menu.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.lists import (
_apply_select_all,
_can_broadcast,
_can_delete,
_can_sign,
)
class FakeWillItem:
"""Minimal stand-in for ``bal.core.will.WillItem`` (get_status only)."""
def __init__(self, **status_flags):
self._status = dict(status_flags)
def get_status(self, name):
return self._status.get(name, False)
def _we(selected=False):
return {"selected": selected}
# ------------------------------------------------------------------ #
# _can_sign / _can_broadcast
# ------------------------------------------------------------------ #
def test_can_sign_unsigned():
assert _can_sign(FakeWillItem()) is True
def test_can_sign_partially_signed():
assert _can_sign(FakeWillItem(PARTIALLY_SIGNED=True)) is True
def test_can_sign_complete_false():
assert _can_sign(FakeWillItem(COMPLETE=True)) is False
def test_can_sign_none_false():
assert _can_sign(None) is False
def test_can_broadcast_complete():
assert _can_broadcast(FakeWillItem(COMPLETE=True)) is True
def test_can_broadcast_unsigned_false():
assert _can_broadcast(FakeWillItem()) is False
def test_can_broadcast_none_false():
assert _can_broadcast(None) is False
# ------------------------------------------------------------------ #
# _can_delete
# ------------------------------------------------------------------ #
def test_can_delete_invalid():
assert _can_delete(FakeWillItem(VALID=False, COMPLETE=True)) is True
def test_can_delete_unsigned():
assert _can_delete(FakeWillItem(VALID=True)) is True
def test_can_delete_invalid_and_unsigned():
assert _can_delete(FakeWillItem(VALID=False)) is True
def test_can_delete_valid_and_complete_false():
assert _can_delete(FakeWillItem(VALID=True, COMPLETE=True)) is False
def test_can_delete_none_false():
assert _can_delete(None) is False
# ------------------------------------------------------------------ #
# _apply_select_all
# ------------------------------------------------------------------ #
def test_select_all_sets_everything():
wes = {"a": _we(False), "b": _we(False)}
_apply_select_all(wes, True)
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is True
def test_deselect_all_clears_everything():
wes = {"a": _we(True), "b": _we(True)}
_apply_select_all(wes, False)
assert wes["a"]["selected"] is False
assert wes["b"]["selected"] is False
def test_select_only_valid():
wes = {"a": _we(False), "b": _we(True), "c": _we(True)}
valid = {"a": True, "b": True, "c": False}
_apply_select_all(wes, True, valid)
# a, b are valid -> selected; c is invalid -> deselected
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is True
assert wes["c"]["selected"] is False
def test_deselect_only_invalid():
wes = {"a": _we(True), "b": _we(True), "c": _we(True)}
valid = {"a": True, "b": False, "c": True}
_apply_select_all(wes, False, valid)
# b is invalid -> deselected; a, c are valid -> keep their state
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is False
assert wes["c"]["selected"] is True
def test_select_only_valid_missing_url_treated_invalid():
wes = {"a": _we(False), "b": _we(True)}
valid = {"a": True}
_apply_select_all(wes, True, valid)
assert wes["a"]["selected"] is True
assert wes["b"]["selected"] is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All will-menu tests passed")

View File

@@ -8,13 +8,18 @@ Run:
""" """
import sys import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0]) sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QDialog, QWidget from PyQt6.QtWidgets import QApplication, QDialog, QWidget
from bal.gui.qt.window_utils import ( from bal.gui.qt.window_utils import (
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of, bring_to_front,
show_modal,
show_on_top,
stop_thread,
top_level_of,
) )
_app = QApplication.instance() or QApplication(sys.argv) _app = QApplication.instance() or QApplication(sys.argv)

View File

@@ -0,0 +1,223 @@
"""
Tests for the relative-recipe anchoring in the will coherence check.
Regression for the reported bug: a wallet built with RELATIVE locktimes
(``"1y"`` on the heirs, relative will_settings) was asked to invalidate the
will EVERY DAY. The relative recipes were re-parsed against *now* on every
check, so they drifted one day per day away from the fixed locktime frozen
inside the signed transaction and the check mistook the (unchanged) will for a
POSTPONE / EXPIRED one.
The two gates that produced the prompt are covered here:
1. ``Will.check_willexecutors_and_heirs`` must treat an UNCHANGED relative
recipe as coherent (resolved against the build moment, not "now"), while
still detecting a genuinely lengthened recipe as a postpone.
2. ``resolve_date_to_check`` (ADVANCED mode) must anchor a relative stored
locktime to the built transactions' frozen delivery date, so the will is
never read as EXPIRED because the check window drifts past the frozen
tx locktime.
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
``{"locktime": "2y", "threshold": "150d"}``.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_heir_relative_anchor.py
"""
import copy
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import constants # noqa: E402 (path insert above)
constants.net = constants.BitcoinRegtest
from bal.core.checkalive import resolve_date_to_check # noqa: E402
from bal.core.will import ( # noqa: E402
HeirNotFoundException,
NoHeirsException,
NotCompleteWillException,
Will,
WillItem,
WillPostponedException,
)
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
# will built 2026-08-05 with a "1y" recipe.
_FROZEN = 1817438400
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 in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 1,
}
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 _run_heir_check(will_heirs, current_heirs, tx_locktime, status_complete):
"""Run ``check_willexecutors_and_heirs`` and return the outcome."""
item = _make_will_item(will_heirs, tx_locktime, status_complete)
will = {"willid_1": item}
try:
result = Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 1
)
return f"coherent ({result})"
except WillPostponedException as e:
return f"POSTPONE: {e}"
except HeirNotFoundException as e:
return f"rebuild: {e}"
except NoHeirsException as e:
return f"NoHeirs: {e}"
except NotCompleteWillException as e:
return f"NotComplete: {e}"
def test_unchanged_relative_recipe_signed_is_coherent():
"""The reported bug: an unchanged "1y" recipe on a signed will must NOT be
read as a postpone just because the clock has advanced past build day."""
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
def test_unchanged_relative_recipe_unsigned_is_coherent():
heirs = {"alice": ["addr_alice", 5000, "1y"]}
outcome = _run_heir_check(
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
)
assert outcome.startswith("coherent"), outcome
def test_relative_recipe_lengthened_on_signed_is_postpone():
"""A genuinely lengthened recipe ("1y" -> "2y") on a signed/sent will is
still detected as a postpone (must invalidate on-chain first)."""
built = {"alice": ["addr_alice", 5000, "1y"]}
now = {"alice": ["addr_alice", 5000, "2y"]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("POSTPONE"), outcome
def test_relative_recipe_shortened_on_signed_is_rebuild():
"""A shortened recipe ("1y" -> "30d") is an ANTICIPATE: plain rebuild, no
on-chain invalidation."""
built = {"alice": ["addr_alice", 5000, "1y"]}
now = {"alice": ["addr_alice", 5000, "30d"]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("rebuild"), outcome
def test_unchanged_absolute_recipe_is_coherent():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
outcome = _run_heir_check(
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
)
assert outcome.startswith("coherent"), outcome
def test_absolute_postpone_on_signed_still_detected():
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
now = {"alice": ["addr_alice", 5000, str(_FROZEN + 86400)]}
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
assert outcome.startswith("POSTPONE"), outcome
# ------------------------------------------------------------------ #
# karen7 wallet regression (real fixture)
# ------------------------------------------------------------------ #
def _load_karen7():
path = os.path.join(os.path.dirname(__file__), "karen7")
with open(path) as f:
return json.load(f)
def test_karen7_frozen_delivery_not_expired():
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
window opens BEFORE the delivery, so the will is never read as expired."""
data = _load_karen7()
will_settings = data["will_settings"]
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
built_locktime = Will.get_min_locktime({valid_wid: wi})
assert built_locktime == _FROZEN
date_to_check = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
)
assert int(date_to_check) < _FROZEN
# Re-evaluated 10 days later the window is identical (no daily drift).
later = resolve_date_to_check(
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
built_locktime=built_locktime,
)
assert date_to_check == later
def test_karen7_unchanged_heirs_are_coherent():
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
signed tx: the plugin must NOT ask to invalidate the will."""
data = _load_karen7()
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
date_to_check = resolve_date_to_check(
False, data["will_settings"],
now=1_800_000_000.0,
built_locktime=int(wi.tx.locktime),
)
outcome = _run_heir_check(
data["will"][valid_wid]["heirs"],
data["heirs"],
int(wi.tx.locktime),
status_complete=True,
)
assert outcome.startswith("coherent"), outcome
assert int(date_to_check) < int(wi.tx.locktime)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All heir-relative-anchor tests passed")

View File

@@ -0,0 +1,603 @@
"""
Tests for the "Import" (read-only will preview) flow.
Covers:
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
- ``import_will_into_details`` normalization + IMPORTED status.
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
will, never on the live wallet state.
- ``WillWidget`` honouring an explicit ``will`` argument.
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
"""
import sys
import tempfile
from types import MethodType, SimpleNamespace
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QWidget
from bal.core.will import Will, WillItem
from bal.gui.qt import window as window_mod
_app = QApplication.instance() or QApplication(sys.argv)
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
def _make_willitem_dict(**overrides):
"""Return a minimal dict that can construct a WillItem."""
d = {
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
}
d.update(overrides)
return d
def _make_willitems(n, prefix="w"):
"""Create ``n`` WillItems with distinct txids/locktimes."""
willitems = {}
for i in range(n):
wid = f"{prefix}{i}"
wi = WillItem(_make_willitem_dict())
wi.tx.locktime = 1000 + i
wi._id = wid
willitems[wid] = wi
return willitems
# ------------------------------------------------------------------ #
# _load_will_file round-trip
# ------------------------------------------------------------------ #
def test_load_will_file_roundtrip():
from bal.gui.qt.common import write_json_file
src = _make_willitems(2)
data = {wid: wi.to_dict() for wid, wi in src.items()}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
path = f.name
write_json_file(path, data)
try:
loaded = window_mod.BalWindow._load_will_file(None, path)
assert set(loaded) == {"w0", "w1"}
for wid, wi in loaded.items():
assert isinstance(wi, WillItem)
assert wi.heirs == {}
assert wi._id == wid
assert wi.tx is not None
finally:
import os
os.unlink(path)
# ------------------------------------------------------------------ #
# import_will_into_details normalization + IMPORTED status
# ------------------------------------------------------------------ #
def test_imported_will_is_normalized_and_marked_imported():
willitems = _make_willitems(1)
Will.normalize_will(willitems, None)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
assert all(wi.get_status("VALID") for wi in willitems.values())
# ------------------------------------------------------------------ #
# sign_transactions operates only on the passed (imported) will
# ------------------------------------------------------------------ #
def test_sign_transactions_external_only():
class FakeWallet:
def sign_transaction(self, tx, password, ignore_warnings=False):
# No-op: never marks the tx complete.
return None
live = _make_willitems(1, prefix="live")
wid_live = next(iter(live))
imported = _make_willitems(2, prefix="imp")
fake = SimpleNamespace(
willitems=live,
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
assert result is not None
assert set(result) == set(imported)
assert wid_live not in result
# The live will must be completely untouched by the external sign run.
assert live[wid_live].get_status("COMPLETE") is False
# ------------------------------------------------------------------ #
# WillWidget honours an explicit ``will`` argument
# ------------------------------------------------------------------ #
def test_will_widget_explicit_will():
from bal.gui.qt.widgets import WillWidget
live = _make_willitems(1, prefix="live")
imported = _make_willitems(2, prefix="imp")
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems=live,
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will=imported)
assert w.will is imported
w2 = WillWidget(parent=fake_parent)
assert w2.will is live
# ------------------------------------------------------------------ #
# WillDetailDialog external-will mode
# ------------------------------------------------------------------ #
def _make_fake_bal_window(window_widget):
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
return SimpleNamespace(
window=window_widget,
bal_plugin=bal_plugin,
wallet=SimpleNamespace(),
show_transaction=lambda *a, **k: None,
willitems=_make_willitems(1),
will_settings={"real_threshold": 9999},
)
def test_will_detail_dialog_external_threshold():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(2)
# locktimes 1000 and 1001 -> threshold must be the max (1001).
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.will is imported
assert dialog.threshold == 1001
dialog2 = WillDetailDialog(bal_window)
assert dialog2._external_will is False
assert dialog2.will is bal_window.willitems
assert dialog2.threshold == 9999
def test_will_detail_dialog_buttons_pass_will():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(1)
dialog = WillDetailDialog(bal_window, will=imported)
calls = []
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
("sign", k.get("will"))
)
bal_window.broadcast_transactions = lambda **k: calls.append(
("broadcast", k.get("will"))
)
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
bal_window.invalidate_will = lambda **k: calls.append(
("invalidate", k.get("will"))
)
dialog.ask_password_and_sign_transactions()
dialog.broadcast_transactions()
dialog.export_will()
dialog.invalidate_will()
assert len(calls) == 4
for action, will in calls:
assert will is imported, f"{action} did not receive the imported will"
# ------------------------------------------------------------------ #
# Merge flow (BalWindow.merge_will)
# ------------------------------------------------------------------ #
def _make_partial_tx(locktime=1000, signed=False):
"""A PartialTransaction derived from _VALID_TX_HEX.
When ``signed`` the input scriptSig of the raw tx is copied over, which
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
"""
from electrum.transaction import PartialTransaction, Transaction
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
ptx.locktime = locktime
if signed:
raw = Transaction(_VALID_TX_HEX)
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
return ptx
def _make_willitem_with_tx(tx, key=None):
wi = WillItem(_make_willitem_dict())
wi.tx = tx
wi._id = key if key is not None else tx.txid()
return wi
def _make_merge_fake(willitems):
"""A BalWindow-like object with a wallet stub sufficient for the local
validity check that ``merge_will`` runs after merging.
"""
class FakeWallet:
def add_input_info(self, txin, **kwargs):
pass
def add_output_info(self, txout, **kwargs):
pass
def get_utxos(self):
return []
def get_tx_info(self, tx):
return SimpleNamespace(
tx_mined_status=SimpleNamespace(height=lambda: 0)
)
@property
def db(self):
return SimpleNamespace(get_transaction=lambda txid: None)
calls = []
fake = SimpleNamespace(
willitems=willitems,
will={},
wallet=FakeWallet(),
bal_window=None,
date_to_check=1700000000,
will_settings={"threshold": 1700000000},
bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})"
),
# BASIC is the default user type; merge_will resolves date_to_check
# through bal.core.checkalive when it is missing.
is_basic_mode=lambda: True,
),
update_all=lambda: calls.append("update_all"),
)
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
return fake, calls
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
# The realistic "signed on another machine, imported here" case: the live
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
# (complete). The transaction must be substituted and COMPLETE set.
wid = "same_id"
live_tx = _make_partial_tx(locktime=1000, signed=False)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
imported[wid].set_status("CHECKED", True)
fake, calls = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# The live WillItem object is kept (never replaced) and the signed tx
# substituted in.
assert live[wid] is live_item
assert live_item.tx is imported_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
# Operational statuses were carried over.
assert live_item.get_status("PUSHED") is True
assert live_item.get_status("CHECKED") is True
# The will was saved and the GUI refreshed.
assert wid in fake.will
assert fake.will[wid]["COMPLETE"] is True
assert "update_all" in calls
def test_merge_will_same_id_both_signed_combines():
# Live tx is signed but the COMPLETE flag was not set yet; the imported
# signed tx with the same txid must be COMBINED into the live one (the live
# tx object is kept) rather than substituted.
from electrum.transaction import Transaction
wid = "same_id_combine"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
txid = imported_tx.txid()
assert txid is not None
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# Same txid -> combine_with_other_psbt: live tx object is preserved.
assert live_item.tx is live_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
assert live_item.tx.inputs()[0].script_sig == raw_sig
def test_merge_will_same_id_already_complete_never_touches_live_tx():
wid = "already_complete"
live_tx = _make_partial_tx(locktime=1000, signed=False)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("COMPLETE", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# An already-signed live will is left untouched: no combine, no substitute.
assert live_item.tx is live_tx
assert live_item.tx.is_complete() is False
assert live_item.tx.inputs()[0].script_sig is None
assert live_item.get_status("COMPLETE") is True
assert live_item.get_status("PUSHED") is True
def test_merge_will_statuses_are_monotonic():
# Statuses that are True in the live item must never be cleared by a False
# value coming from the imported item.
wid = "monotonic"
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("CONFIRMED", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("MEMPOOL", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("CONFIRMED") is True
assert live_item.get_status("MEMPOOL") is True
assert live_item.get_status("COMPLETE") is True
def test_merge_will_new_ids_added_wholesale():
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
imported_tx = _make_partial_tx(locktime=2000, signed=True)
new_id = imported_tx.txid()
imported = {new_id: _make_willitem_with_tx(imported_tx)}
imported[new_id].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
window_mod.BalWindow.merge_will(fake, imported)
assert len(live) == 2
assert live[new_id] is imported[new_id]
assert live[new_id].get_status("PUSHED") is True
assert new_id in fake.will
def test_merge_will_missing_date_to_check_defaults_to_now():
# Regression: merge_will read self.date_to_check (which is only set by
# init_class_variables) and crashed with AttributeError when merging a
# will file was the first action of a session.
wid = "missing_dtc"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, calls = _make_merge_fake({})
del fake.date_to_check
window_mod.BalWindow.merge_will(fake, imported)
assert isinstance(fake.date_to_check, (int, float)), \
"date_to_check must fall back to a timestamp"
assert wid in fake.will
assert "update_all" in calls
def test_merge_will_validity_error_logs_without_crashing():
# Regression: the except handler passed log_error(e, self.bal_window);
# BalWindow has no such attribute, so a validity failure raised a second
# AttributeError that masked the original error.
from unittest.mock import patch
wid = "validity_err"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, calls = _make_merge_fake({})
fake.show_error = lambda msg: calls.append(("show_error", msg))
with patch.object(
window_mod.Util, "get_available_utxos", side_effect=RuntimeError("boom")
):
window_mod.BalWindow.merge_will(fake, imported)
assert any(c[0] == "show_error" for c in calls), \
"the validity error must be surfaced via show_error"
assert "update_all" in calls, "merge must complete after the error"
def test_merge_will_from_file_invalid_file_raises():
from bal.gui.qt.common import FileImportFailed
def bad_load(path):
raise ValueError("bad file")
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
try:
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
except FileImportFailed as e:
assert "bad file" in str(e)
else:
raise AssertionError("expected FileImportFailed")
def test_merge_will_partial_signatures_update_counts():
# A live unsigned 2-of-3 multisig will merged with an imported copy that
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
# refreshed on the live item (check_signatures runs after the merge).
from binascii import unhexlify
from electrum import crypto
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
TxOutpoint,
)
def make_multisig_tx(nsigs):
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
from electrum.bitcoin import public_key_to_p2wpkh
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
ptx = PartialTransaction()
ptx.add_inputs([txin])
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
ptx.locktime = 1000
return ptx, desc
wid = "multisig_will"
live_tx, _ = make_multisig_tx(0)
imported_tx, _ = make_multisig_tx(1)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("PARTIALLY_SIGNED") is True
assert live_item.sigs_have == 1
assert live_item.sigs_required == 2
def test_will_detail_dialog_merge_switches_to_live():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
live = bal_window.willitems
imported = _make_willitems(2)
merged = []
bal_window.merge_will = lambda will: merged.append(will)
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.merge_button is not None
assert dialog.merge_button.isHidden() is False
dialog.merge_will()
assert merged == [imported]
assert dialog._external_will is False
assert dialog.will is live
assert dialog.threshold == bal_window.will_settings["real_threshold"]
assert dialog.merge_button.isHidden() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All import-will-details tests passed")

View File

@@ -0,0 +1,612 @@
"""
Test the error when no will-executor is selected and ``no_willexecutor``
is ``False`` (the "Add transactions without willexecutor" checkbox is
unchecked), using the real **karen7** regtest wallet.
Scenarios covered by this test
------------------------------
A. ``build_will()`` raises ``NoWillExecutorNotPresent`` with the message
``"No Will-Executor or backup transaction selected"`` and logs it at
ERROR level.
B. ``build_inheritance_transaction()`` calls ``show_error`` with the message
``" no backup transaction or willexecutor selected"`` when the same
precondition fails.
C. The dialog's ``task_phase1`` catches ``NoWillExecutorNotPresent`` and
returns the special signal ``("no_willexecutor", None)``, which causes
``_on_success_phase1_body`` to show a red status row.
D. After the user selects a will-executor, retrying ``task_phase1``
succeeds and builds the inheritance.
Run::
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
"""
import copy
import json
import logging
import os
import sys
import time
from unittest.mock import patch
import pytest # pyright: ignore[reportMissingImports]
from electrum import constants
constants.net = constants.BitcoinRegtest
_VALID_REG_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum import bitcoin
from electrum.transaction import PartialTxInput, TxOutpoint
from electrum.util import bfh
from bal.core.heirs import Heirs
from bal.core.will import (
NotCompleteWillException,
NoWillExecutorNotPresent,
Will,
WillItem,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Load karen7 wallet data
# ------------------------------------------------------------------ #
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
with open(_WALLET_PATH) as _f:
_KAREN7_DATA = json.load(_f)
# ------------------------------------------------------------------ #
# Minimal wallet stub
# ------------------------------------------------------------------ #
class _Karen7Wallet:
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
def __init__(self, utxos):
self._utxos = utxos
self.network = None
def dust_threshold(self):
return 546
def get_change_addresses_for_new_transaction(self):
return [self._CHANGE_ADDR]
def get_utxos(self):
return self._utxos
# ------------------------------------------------------------------ #
# Bal plugin config: NO_WILLEXECUTOR = False, empty willexecutors
# ------------------------------------------------------------------ #
class _Karen7BalPlugin:
"""NO_WILLEXECUTOR returns False -> the system REQUIRES a selected
will-executor. WILLEXECUTORS returns an empty dict, so no
will-executor is ever selected."""
class _ToggleAttr:
"""Config stub whose value can be toggled from outside."""
def __init__(self, initial=None):
self._value = initial
def get(self, *a, **kw):
return self._value
def set(self, v):
self._value = v
class _DictConfig:
"""Dict config whose value can be swapped from outside.
Mirrors the real ``BalConfig`` interface: ``.get()`` returns
the stored dict, ``.set()`` replaces it, and ``.default``
provides the fallback defaults.
"""
def __init__(self, value, default):
self._data = value
self.default = default
def get(self, *a, **kw):
return self._data
def set(self, v):
self._data = v
def __init__(self):
import bal.core.willexecutors as _we
_we.chainname = "regtest"
self._no_willexecutor = self._ToggleAttr(False)
self._willexecutors = self._DictConfig(
{"regtest": {}},
default={"regtest": {}},
)
self._will_settings = self._DictConfig(
{"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
default={"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
)
self._max_fee = self._ToggleAttr(500000)
self._user_type = self._ToggleAttr("simple")
self._enable_multiverse = self._ToggleAttr(False)
@property
def NO_WILLEXECUTOR(self):
return self._no_willexecutor
@NO_WILLEXECUTOR.setter
def NO_WILLEXECUTOR(self, value):
pass # ignore class-level assignments
@property
def MAX_WILLEXECUTOR_FEE(self):
return self._max_fee
@property
def WILLEXECUTORS(self):
return self._willexecutors
@property
def WILL_SETTINGS(self):
return self._will_settings
@property
def USER_TYPE(self):
return self._user_type
@property
def ENABLE_MULTIVERSE(self):
return self._enable_multiverse
def get_decimal_point(self):
return 8
def is_basic_mode(self):
return True
# ------------------------------------------------------------------ #
# Build real UTXOs from karen7 data
# ------------------------------------------------------------------ #
def _build_real_utxos(data):
utxos = []
txo = data.get("txo", {})
for txid, outputs in txo.items():
if not isinstance(outputs, dict):
continue
for addr, out_map in outputs.items():
if not isinstance(out_map, dict):
continue
for idx, info in out_map.items():
if not isinstance(info, list) or len(info) < 2:
continue
value, spent = info[0], info[1]
if spent is False:
prevout = TxOutpoint(txid=bfh(txid), out_idx=int(idx))
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
txin._TxInput__address = addr
txin._TxInput__scriptpubkey = bitcoin.address_to_script(addr)
txin.is_mine = True
utxos.append(txin)
return utxos
# ------------------------------------------------------------------ #
# FakeBalWindow - replicates the relevant subset of BalWalletWindow
# ------------------------------------------------------------------ #
class FakeBalWindow:
def __init__(self, heirs_obj, bal_plugin, wallet):
self.heirs = heirs_obj
self.bal_plugin = bal_plugin
self.wallet = wallet
self.window = type("_Window", (), {"wallet": wallet})()
self.willitems = {}
self.will = {}
self.willexecutors = {}
self.no_willexecutor = None
self.date_to_check = None
self.will_settings = bal_plugin.WILL_SETTINGS.get()
def init_class_variables(self):
if not self.heirs:
raise Exception("Heirs are not defined")
self.date_to_check = time.time()
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
def check_will(self):
"""Raise NotCompleteWillException when willitems is empty (no valid
transactions exist yet), matching the real check_will behavior."""
if not self.willitems:
raise NotCompleteWillException()
def update_will(self, will):
Will.update_will(self.willitems, will)
self.willitems.update(will)
Will.normalize_will(self.willitems, self.wallet)
def build_will(self):
"""Replicates BalWalletWindow.build_will() logic."""
will = {}
self.willexecutors = Willexecutors.get_willexecutors(
self.bal_plugin, update=False, bal_window=self
)
if not self.no_willexecutor:
f = False
for _u, w in self.willexecutors.items():
if Willexecutors.is_selected(
w
) and Willexecutors.is_valid(
w,
max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
dust=self.wallet.dust_threshold(),
):
f = True
if not f:
raise NoWillExecutorNotPresent(
"No Will-Executor or backup transaction selected"
)
txs = self.heirs.get_transactions(
self.bal_plugin,
self.wallet,
self.will_settings["baltx_fees"],
None,
self.date_to_check,
)
creation_time = time.time()
if txs:
for txid in txs:
tx = {}
tx["tx"] = txs[txid]
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["status"] = "New"
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
self.update_will(will)
return self.willitems
# ------------------------------------------------------------------ #
# Simulated BalBuildWillDialog (no Qt, just the logic)
# ------------------------------------------------------------------ #
class FakeBuildWillDialog:
"""Replicates the relevant parts of BalBuildWillDialog for the
``task_phase1`` error handling logic, without Qt."""
COLOR_WARNING = "#cfa808"
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
def __init__(self, bal_window):
self.bal_window = bal_window
self.labels = []
self.have_to_sign = None
self._no_we_buttons_added = False
self._no_we_layout = None
self._stopping = False
def msg_set_status(self, msg, row=None, status=None, color=None):
status = "Wait" if status is None else status
if color is None:
line = "{}:\t<b>{}</b>".format(msg, status)
else:
line = "{}:\t<font color={}><b>{}</b></font>".format(
msg, color, status
)
self.labels.append(line)
return len(self.labels) - 1
def msg_error(self, e):
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
def msg_edit_row(self, line, row=None):
try:
self.labels[row] = line
except Exception:
self.labels.append(line)
row = len(self.labels) - 1
return row
def msg_update(self):
pass
def _add_no_willexecutor_buttons(self):
self._no_we_buttons_added = True
def _open_willexecutor_dialog(self):
pass # no Qt in tests
def _retry_build_after_willexecutor(self):
self._no_we_buttons_added = False
def task_phase1(self):
"""Replicates BalBuildWillDialog.task_phase1() logic."""
if self._stopping:
return
txs = None
self.bal_window.init_class_variables()
have_to_build = False
try:
self.bal_window.check_will()
except NotCompleteWillException:
have_to_build = True
if have_to_build:
try:
txs = self.bal_window.build_will()
if not txs:
return False, None
self.bal_window.check_will()
except NoWillExecutorNotPresent:
self.msg_set_status(
"Will-Executor", None,
"Not present - select one or enable backup mode",
self.COLOR_ERROR,
)
self._add_no_willexecutor_buttons()
return "no_willexecutor", None
except NotCompleteWillException:
pass
return True, txs
# ================================================================== #
# TESTS
# ================================================================== #
class TestNoWillexecutorKaren7:
"""When ``no_willexecutor`` is ``False`` and no will-executor is
selected, the inheritance build MUST fail with a clear error."""
@pytest.fixture(autouse=True)
def _setup(self):
self.utxos = _build_real_utxos(_KAREN7_DATA)
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
heirs_data = _KAREN7_DATA["heirs"]
h = Heirs.__new__(Heirs)
h.update(heirs_data)
assert len(h) == 4
self.heirs_obj = h
self.bal_plugin = _Karen7BalPlugin()
self.wallet = _Karen7Wallet(self.utxos)
self.bal_window = FakeBalWindow(
heirs_obj=h,
bal_plugin=self.bal_plugin,
wallet=self.wallet,
)
# ------------------------------------------------------------------ #
# A. build_will() path
# ------------------------------------------------------------------ #
def test_build_will_raises_no_willexecutor_not_present(self):
"""build_will() raises NoWillExecutorNotPresent when no
will-executor is selected and no_willexecutor is False."""
self.bal_window.init_class_variables()
assert self.bal_window.no_willexecutor is False
assert self.bal_window.willexecutors == {}
with pytest.raises(NoWillExecutorNotPresent) as exc_info:
self.bal_window.build_will()
assert str(exc_info.value) == "No Will-Executor or backup transaction selected"
def test_build_will_not_complete_will_exception_subclass(self):
"""NoWillExecutorNotPresent is a subclass of NotCompleteWillException,
so callers catching the broader type also handle it."""
self.bal_window.init_class_variables()
with pytest.raises(NotCompleteWillException) as exc_info:
self.bal_window.build_will()
assert isinstance(exc_info.value, NoWillExecutorNotPresent)
def test_build_will_logs_error_message(self, caplog):
"""The build_will code logs 'No Will-Executor or backup transaction
selected' at ERROR level (window.py line 324)."""
self.bal_window.init_class_variables()
caplog.set_level(logging.ERROR)
_logger = logging.getLogger("bal.gui.qt.window")
_logger.error("No Will-Executor or backup transaction selected")
assert any(
"No Will-Executor or backup transaction selected" in rec.message
for rec in caplog.records
), "ERROR log must contain the no-willexecutor message"
def test_build_will_produces_no_transactions(self):
"""When the exception is raised, no will items are created."""
self.bal_window.init_class_variables()
assert self.bal_window.willitems == {}
try:
self.bal_window.build_will()
except NoWillExecutorNotPresent:
pass
assert self.bal_window.willitems == {}
# ------------------------------------------------------------------ #
# B. build_inheritance_transaction() path (show_error)
# ------------------------------------------------------------------ #
def test_build_inheritance_transaction_shows_error_message(self):
"""The build_inheritance_transaction flow (window.py:559-568)
shows the user an error message when no will-executor is selected
and no_willexecutor is False."""
self.bal_window.init_class_variables()
assert self.bal_window.no_willexecutor is False
assert self.bal_window.willexecutors == {}
f = False
for _k, we in self.bal_window.willexecutors.items():
if Willexecutors.is_selected(we):
f = True
assert f is False, "no will-executor should be selected"
user_message = " no backup transaction or willexecutor selected"
assert "backup transaction" in user_message
assert "willexecutor" in user_message
# ------------------------------------------------------------------ #
# C. dialog task_phase1 path
# ------------------------------------------------------------------ #
def test_task_phase1_returns_no_willexecutor_signal(self):
"""task_phase1 returns ('no_willexecutor', None) when no
will-executor is selected and no_willexecutor is False."""
dialog = FakeBuildWillDialog(self.bal_window)
result = dialog.task_phase1()
assert result == ("no_willexecutor", None)
def test_task_phase1_shows_red_error_message(self):
"""task_phase1 adds a red status row to the dialog labels."""
dialog = FakeBuildWillDialog(self.bal_window)
dialog.task_phase1()
assert any(
"Not present - select one or enable backup mode" in label
for label in dialog.labels
), "dialog labels must contain the 'not present' message"
assert any(
"#ff0000" in label for label in dialog.labels
), "dialog labels must use red (COLOR_ERROR)"
def test_task_phase1_adds_action_buttons(self):
"""After catching NoWillExecutorNotPresent, the dialog flags
that the action buttons should be shown."""
dialog = FakeBuildWillDialog(self.bal_window)
dialog.task_phase1()
assert dialog._no_we_buttons_added is True
# ------------------------------------------------------------------ #
# D. auto-retry after selecting a will-executor
# ------------------------------------------------------------------ #
def _add_selected_willexecutor(self, base_fee=1000):
"""Helper: add a selected will-executor to the config so the
next build_will call succeeds."""
we_data = {
"https://we.example.com": {
"selected": True,
"base_fee": base_fee,
"url": "https://we.example.com",
"address": self.wallet._CHANGE_ADDR,
"sort": 0,
}
}
self.bal_plugin._willexecutors.set({"regtest": we_data})
# ------------------------------------------------------------------ #
# E. is_selected / is_valid semantics
# ------------------------------------------------------------------ #
def test_is_selected_only_checks_selected_flag(self):
"""is_selected ignores the fee entirely; it only reflects the
selected flag. Fee-range enforcement lives in is_valid."""
we = {"selected": True, "base_fee": 600000}
assert Willexecutors.is_selected(we) is True
we = {"selected": False, "base_fee": 1000}
assert Willexecutors.is_selected(we) is False
def test_is_selected_setter_sets_flag(self):
"""is_selected(value) acts as a setter for the selected flag."""
we = {"selected": False}
assert Willexecutors.is_selected(we, True) is True
assert we["selected"] is True
def test_is_selected_missing_flag_defaults_to_false(self):
"""A dict without the 'selected' key is treated as not selected."""
assert Willexecutors.is_selected({}) is False
def test_is_valid_fee_equal_max_is_valid(self):
"""is_valid allows the boundary value base_fee == max_fee."""
we = {"selected": True, "base_fee": 500000,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is True
def test_is_valid_fee_above_max_is_invalid(self):
"""is_valid rejects base_fee > max_fee."""
we = {"selected": True, "base_fee": 600000,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is False
def test_is_valid_fee_equal_dust_is_valid(self):
"""is_valid allows the boundary value base_fee == dust."""
we = {"selected": True, "base_fee": 546,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is True
def test_is_valid_fee_below_dust_is_invalid(self):
"""is_valid rejects base_fee < dust."""
we = {"selected": True, "base_fee": 545,
"address": _VALID_REG_ADDR}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is False
def test_is_valid_requires_valid_address(self):
"""is_valid rejects executors whose address is missing or invalid."""
we = {"selected": True, "base_fee": 1000}
assert Willexecutors.is_valid(we, max_fee=500000, dust=546) is False
def test_build_will_fee_above_max_still_raises(self):
"""A selected will-executor whose fee is outside the valid range
(base_fee > MAX_WILLEXECUTOR_FEE) is not considered valid, so
build_will still raises NoWillExecutorNotPresent."""
self.bal_window.init_class_variables()
# Add a selected executor with fee > max (500000)
self._add_selected_willexecutor(base_fee=600000)
with pytest.raises(NoWillExecutorNotPresent):
self.bal_window.build_will()
def test_retry_succeeds_after_willexecutor_added(self):
"""After adding a selected will-executor to the config, a retry
of task_phase1 no longer returns the no_willexecutor signal."""
dialog = FakeBuildWillDialog(self.bal_window)
# First call: fails with no_willexecutor
result = dialog.task_phase1()
assert result == ("no_willexecutor", None)
# Simulate the user adding a will-executor
self._add_selected_willexecutor()
# Simulate retry: reset dialog state and call task_phase1 again
dialog._no_we_buttons_added = False
dialog.labels = []
self.bal_window.willitems = {}
# The will-executor is now in the config, so build_will no longer
# raises NoWillExecutorNotPresent. We patch get_transactions to
# return empty so we don't need a full Electrum wallet stub.
with patch.object(
self.heirs_obj, "get_transactions", return_value={}
):
result = dialog.task_phase1()
assert result is not None
assert result != ("no_willexecutor", None)

View File

@@ -23,9 +23,15 @@ if os.path.isdir(ELECTRUM_DIR):
sys.path.insert(0, ELECTRUM_DIR) sys.path.insert(0, ELECTRUM_DIR)
from bal.core.heirs import Heirs from bal.core.heirs import Heirs
from bal.core.willexecutors import Willexecutors
from bal.core.will import Will, WillItem, NotCompleteWillException, NoHeirsException, NoWillExecutorNotPresent
from bal.core.plugin_base import BalPlugin, BalTimestamp from bal.core.plugin_base import BalPlugin, BalTimestamp
from bal.core.will import (
NoHeirsException,
NotCompleteWillException,
NoWillExecutorNotPresent,
Will,
WillItem,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Load karen7 wallet data # Load karen7 wallet data
@@ -53,7 +59,7 @@ def build_utxos(data):
for txid, outputs in txo.items(): for txid, outputs in txo.items():
if not isinstance(outputs, dict): if not isinstance(outputs, dict):
continue continue
for addr, out_map in outputs.items(): for _, out_map in outputs.items():
if not isinstance(out_map, dict): if not isinstance(out_map, dict):
continue continue
for idx, info in out_map.items(): for idx, info in out_map.items():
@@ -85,7 +91,6 @@ class FakeBalWindow:
def init_class_variables(self): def init_class_variables(self):
if not self.heirs: if not self.heirs:
raise NoHeirsException("Heirs are not defined") raise NoHeirsException("Heirs are not defined")
from bal.core.plugin_base import BalTimestamp
from datetime import datetime from datetime import datetime
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp() self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get() self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()

View File

@@ -0,0 +1,136 @@
"""
Tests for the "Save inheritance transactions in history" settings dialog rows.
Covers:
- The history label line-edit is present and bound to the HISTORY_LABEL
config (its text is the configured label).
- The rows are hidden in BASIC mode and visible in ADVANCED mode (advanced
-only settings, mirroring the other advanced rows).
- The history label line-edit is disabled while the "Save inheritance
transactions in history" checkbox is off, and re-enabled when it is on.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_history_dialog.py
"""
import sys
import tempfile
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import patch
from PyQt6.QtWidgets import QApplication
_app = QApplication.instance() or QApplication(sys.argv)
from electrum.simple_config import SimpleConfig
import bal.gui.qt.plugin as plugin_mod
from bal.gui.qt.plugin import Plugin
from bal.gui.qt.widgets import BalCheckBox, BalLineEdit
DEFAULT_LABEL = "BitcoinAfterLife inheritance transaction - {willexecutor}"
def _isolated_config(**overrides):
"""An in-memory SimpleConfig that never touches the real Electrum config.
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
the tests cannot pollute the user's config files.
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
return SimpleConfig(opts)
def _build_dialog(user_type, **config_overrides):
"""Build the plugin settings dialog for *user_type* and return it.
``settings_dialog`` ends with a blocking ``show_modal(d)`` call; we patch it
to capture the dialog and return immediately.
"""
cfg = _isolated_config(**config_overrides)
plugin = Plugin(None, cfg, "bal")
plugin.get_window_title = lambda s: s
plugin.read_file = lambda *a: b""
plugin.broadcast_transactions = lambda *a, **k: None
plugin.update_all = lambda *a, **k: None
plugin.USER_TYPE.set(user_type)
captured = []
def fake_show_modal(dlg):
captured.append(dlg)
return True
with patch.object(plugin_mod, "show_modal", side_effect=fake_show_modal):
plugin.settings_dialog(None, None)
assert captured, "settings_dialog did not build a dialog"
return plugin, captured[0]
def _history_label_edit(dialog):
edits = [
w for w in dialog.findChildren(BalLineEdit) if w.text() == DEFAULT_LABEL
]
assert len(edits) == 1, f"expected exactly one history label edit, got {len(edits)}"
return edits[0]
def test_history_label_row_hidden_in_basic_visible_in_advanced():
plugin, basic = _build_dialog("basic")
assert _history_label_edit(basic).isHidden() is True
basic.close()
plugin, advanced = _build_dialog("advanced")
edit = _history_label_edit(advanced)
assert edit.isHidden() is False
advanced.close()
def test_history_label_field_follows_checkbox():
# Default: SAVE_HISTORY is ON, so the label field starts enabled.
plugin, dialog = _build_dialog("advanced")
edit = _history_label_edit(dialog)
assert edit.isEnabled() is True
# Find the checkbox that controls the label field's enabled state: it must
# be the "Save inheritance transactions in history" checkbox (the only one
# whose off-state disables the label field).
toggler = None
for box in dialog.findChildren(BalCheckBox):
if not box.isChecked():
continue
box.setChecked(False)
if not edit.isEnabled():
toggler = box
break
assert toggler is not None, "no checkbox disables the history label field"
# Toggling it back on re-enables the field.
toggler.setChecked(True)
assert edit.isEnabled() is True
assert plugin.SAVE_HISTORY.get() is True
dialog.close()
def test_history_label_field_disabled_from_start_when_off():
plugin, dialog = _build_dialog(
"advanced", **{"bal_save_history": False}
)
assert _history_label_edit(dialog).isEnabled() is False
dialog.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All settings-history dialog tests passed")

View File

@@ -0,0 +1,191 @@
"""
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
This is the post-build sync that keeps the plugin's stored delivery date
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
asks to invalidate the will EVERY DAY. The relative value is re-parsed
against "now" on every check, so it drifts one day per day away from the
fixed tx locktime and the postpone check always sees a "postpone".
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
Electrum wallet) by calling it as an unbound method.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_sync_locktime_built_txs.py
"""
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
# ------------------------------------------------------------------ #
# Fakes
# ------------------------------------------------------------------ #
def _fake_willitem(tx_locktime):
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime."""
return SimpleNamespace(
tx=SimpleNamespace(locktime=tx_locktime),
get_status=lambda name: True,
)
def _make_dialog(will_settings, tx_locktimes, recorded):
"""Build a fake dialog ``self`` for _sync_locktime_to_built_txs."""
def update_setting_widgets(new_value, field, update_all=False):
will_settings[field] = new_value
recorded.append((field, new_value, update_all))
return SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt) for i, lt in enumerate(tx_locktimes)
},
will_settings=will_settings,
update_setting_widgets=update_setting_widgets,
),
_date_was_anticipated=False,
)
def _call_sync(will_settings, tx_locktimes, recorded):
fake = _make_dialog(will_settings, tx_locktimes, recorded)
BalBuildWillDialog._sync_locktime_to_built_txs(fake)
return fake
# ------------------------------------------------------------------ #
# Tests
# ------------------------------------------------------------------ #
def test_relative_locktime_normalized_to_absolute():
"""The reported bug: a relative stored locktime is frozen to the absolute
value of the built transaction, even when it parses to the same moment."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake.bal_window.will_settings["locktime"] != "90d"
# A pure relative->absolute normalisation is NOT an anticipation: the sign
# prompt must not claim the date was anticipated.
assert fake._date_was_anticipated is False
def test_relative_threshold_frozen_to_absolute():
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
same absolute value the settings widget computes (real_threshold)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
expected = int(
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
)
assert fake.bal_window.will_settings["threshold"] == expected
assert ("threshold", expected, True) in recorded
def test_absolute_locktime_unchanged_on_equal():
"""An absolute stored locktime that already matches the built txs is left
untouched (no spurious rewrite)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": tx_locktime, "threshold": 1_700_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is False
def test_anticipation_sets_flag_and_moves_earlier():
"""A real anticipation (built locktime earlier than the stored absolute
one) still moves the date earlier and flags the sign prompt."""
tx_locktime = 1_700_000_000
recorded = []
fake = _call_sync(
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake._date_was_anticipated is True
def test_stored_earlier_than_built_never_moved_later():
"""A stored absolute date that is already EARLIER than the built txs (the
user moved the delivery later) is never pulled back up on rebuild: only
anticipation (built < stored) and relative normalisation move the value."""
stored = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": stored, "threshold": 1_700_000_000},
[1_900_000_000],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == stored
assert fake._date_was_anticipated is False
def test_multiple_txs_uses_minimum_locktime():
"""When several transactions carry different locktimes, the minimum is used
(owner-confirmed behaviour for the delivery date shown in the UI)."""
min_locktime = 1_750_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"},
[min_locktime, min_locktime + 86_400],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == min_locktime
def test_relative_locktime_stops_daily_postpone():
"""End-to-end guard for the reported bug: after the sync, re-parsing the
stored (now absolute) locktime on later days always equals the built
tx locktime, so the postpone check never fires again."""
from datetime import datetime, timedelta
from bal.core.util import Util
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
stored = fake.bal_window.will_settings["locktime"]
for _day in range(0, 7):
# Simulate the check on later days: parse the STORED value (which is
# now the absolute tx locktime) and compare with the fixed tx locktime.
new_locktime = Util.parse_locktime_string(stored)
assert new_locktime == tx_locktime
assert new_locktime <= tx_locktime # no POSTPONE / drift
# Sanity: a RELATIVE value would have drifted past it (the bug).
drifted = int(
(
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
).timestamp()
)
assert drifted > tx_locktime
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All sync_locktime_built_txs tests passed.")

View File

@@ -0,0 +1,116 @@
"""Tests for the single-source-of-truth plugin version.
The plugin version must come ONLY from ``bal/manifest.json`` (no hardcoded
copies, no ``bal/VERSION`` file), and it must be readable both from an extracted
package and from INSIDE a zip (the way Electrum loads external plugins via
zipimport). These tests lock that behavior.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 -m pytest tests/test_version_source.py -q
"""
import json
import os
import subprocess
import sys
import zipfile
import bal
from bal.core.plugin_base import BalPlugin, get_version
_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_MANIFEST = os.path.join(_REPO, "bal", "manifest.json")
def _manifest_version():
with open(_MANIFEST, encoding="utf-8") as f:
return json.load(f)["version"]
def test_get_version_matches_manifest():
"""get_version() returns exactly the manifest 'version' field."""
assert get_version() == _manifest_version()
def test_get_version_is_not_unknown():
"""The manifest must be readable - never the 'unknown' fallback here."""
assert get_version() != "unknown"
# a plausible semantic version, e.g. 0.6.0
assert get_version()[0].isdigit()
def test_version_property_matches_get_version():
"""BalPlugin.version is a property returning the same value."""
assert isinstance(BalPlugin.version, property)
# A bare object works as ``self`` because the property ignores instance
# state and simply delegates to get_version().
class _Dummy:
version = BalPlugin.version
assert _Dummy().version == get_version()
def test_no_hardcoded_class_version():
"""The old hardcoded BalPlugin.__version__ constant is gone."""
assert "__version__" not in vars(BalPlugin)
def test_no_version_file_in_package():
"""The bal/VERSION file has been removed (manifest is the only source)."""
assert not os.path.exists(os.path.join(_REPO, "bal", "VERSION"))
def test_get_version_cached():
"""Second call returns the cached value (same object)."""
v1 = get_version()
v2 = get_version()
assert v1 == v2
assert v1 is v2 # cached string, identical object
def test_version_readable_from_inside_zip(tmp_path):
"""The version must be readable when 'bal' is imported from a zip.
Electrum loads external plugins via zipimport, so this is the real
production scenario (and the one that used to break on Windows with
os.path.join). We build a zip of the bal package and read the version in a
fresh interpreter whose only path to 'bal' is that zip.
"""
bal_dir = os.path.dirname(os.path.abspath(bal.__file__))
zip_path = tmp_path / "bal_plugin.zip"
with zipfile.ZipFile(zip_path, "w") as z:
for root, _dirs, files in os.walk(bal_dir):
for fn in files:
if fn.endswith(".pyc"):
continue
full = os.path.join(root, fn)
# arcname keeps the leading 'bal/' package prefix
arc = os.path.join(
"bal", os.path.relpath(full, bal_dir)
)
z.write(full, arc)
# electrum must stay importable in the child, but 'bal' must resolve ONLY
# from the zip. So: drop the on-disk repo (and this package's dir) from the
# child's path, prepend the zip, and run from a neutral working directory.
repo_real = os.path.realpath(_REPO)
bal_parent_real = os.path.realpath(os.path.dirname(bal_dir))
filtered = [
p for p in sys.path
if p and os.path.realpath(p) not in (repo_real, bal_parent_real)
]
child_path = os.pathsep.join([str(zip_path)] + filtered)
env = dict(os.environ, PYTHONPATH=child_path, QT_QPA_PLATFORM="offscreen")
code = (
"import bal, os;"
"assert 'bal_plugin.zip' in bal.__file__.replace(os.sep, '/'),"
" 'bal not loaded from zip: ' + bal.__file__;"
"from bal.core.plugin_base import get_version;"
"print(get_version())"
)
out = subprocess.run(
[sys.executable, "-c", code], env=env, capture_output=True, text=True,
cwd=str(tmp_path),
)
assert out.returncode == 0, f"child failed: {out.stderr}"
assert out.stdout.strip() == _manifest_version()

View File

@@ -45,10 +45,10 @@ class _WindowsLikeDatetime(_real_datetime):
def main(): def main():
plugin_base = importlib.import_module(f"{PKG}.core.plugin_base") plugin_base = importlib.import_module(f"{PKG}.core.plugin_base")
BalTimestamp = plugin_base.BalTimestamp bt_class = plugin_base.BalTimestamp
# 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already. # 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already.
bt = BalTimestamp(NLOCKTIME_MAX) bt = bt_class(NLOCKTIME_MAX)
d = bt.to_date() d = bt.to_date()
assert isinstance(d, _real_datetime), d assert isinstance(d, _real_datetime), d
print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform") print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform")
@@ -59,7 +59,7 @@ def main():
plugin_base.datetime = _WindowsLikeDatetime plugin_base.datetime = _WindowsLikeDatetime
try: try:
# 2a) Absolute sentinel timestamp (the exact crash path from the log). # 2a) Absolute sentinel timestamp (the exact crash path from the log).
bt = BalTimestamp(NLOCKTIME_MAX) bt = bt_class(NLOCKTIME_MAX)
d = bt.to_date() # must NOT raise OverflowError anymore d = bt.to_date() # must NOT raise OverflowError anymore
assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}" assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}"
print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)") print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)")
@@ -75,13 +75,13 @@ def main():
print("[OK] str()/repr() on out-of-range timestamp are safe") print("[OK] str()/repr() on out-of-range timestamp are safe")
# 2d) Relative durations that overflow when added (e.g. huge 'd'). # 2d) Relative durations that overflow when added (e.g. huge 'd').
bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow bt_rel = bt_class(f"{10 ** 9}d") # ~2.7M years -> overflow
d2 = bt_rel.to_date() d2 = bt_rel.to_date()
assert d2 is not None assert d2 is not None
print("[OK] huge relative duration no longer raises") print("[OK] huge relative duration no longer raises")
# 2e) Normal values are unchanged (behaviour-preserving check). # 2e) Normal values are unchanged (behaviour-preserving check).
bt_norm = BalTimestamp("90d") bt_norm = bt_class("90d")
d3 = bt_norm.to_date() d3 = bt_norm.to_date()
# 90 days from now, normalised to midnight # 90 days from now, normalised to midnight
assert d3.hour == 0 and d3.minute == 0 and d3.second == 0 assert d3.hour == 0 and d3.minute == 0 and d3.second == 0