Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0806332543
|
|||
|
1e80f7a0e0
|
|||
|
693479b0da
|
|||
|
06d61d78d0
|
|||
|
1dc3c79486
|
|||
|
30a5720ceb
|
|||
|
08394f4868
|
|||
|
649910e599
|
|||
|
fb797f31e3
|
|||
|
f73fd14441
|
|||
|
9bf088b7ff
|
|||
|
4a9299d85b
|
|||
| c2eecce029 | |||
|
4b0af6f4bf
|
|||
| f72ed33ea0 | |||
| 6c41a28541 | |||
| 00eefe0525 | |||
|
|
b610bea5a8 | ||
|
|
4ae019cba5 | ||
| 372f3952ca |
29
.gitignore
vendored
29
.gitignore
vendored
@@ -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
76
AGENTS.md
Normal 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).
|
||||||
239
CHANGELOG.md
239
CHANGELOG.md
@@ -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
30
COMPATIBILITY.md
Normal 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
99
COMPATIBILITY_ROADMAP.md
Normal 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.
|
||||||
179
HANDOFF.md
179
HANDOFF.md
@@ -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.
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -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
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
0.5.18
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,14 +171,22 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
if is_op_return_address(heir[HEIR_ADDRESS]):
|
||||||
outputs.append(
|
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
||||||
PartialTxOutput.from_address_and_value(
|
op_return_script = create_op_return_script(data_hex)
|
||||||
heir[HEIR_ADDRESS], real_amount
|
outputs.append(
|
||||||
|
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
||||||
)
|
)
|
||||||
)
|
description += f"{name}\n"
|
||||||
out_amount += real_amount
|
else:
|
||||||
description += f"{name}\n"
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
|
outputs.append(
|
||||||
|
PartialTxOutput.from_address_and_value(
|
||||||
|
heir[HEIR_ADDRESS], real_amount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out_amount += real_amount
|
||||||
|
description += f"{name}\n"
|
||||||
except BitcoinException as e:
|
except BitcoinException as e:
|
||||||
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
||||||
heir[HEIR_REAL_AMOUNT] = e
|
heir[HEIR_REAL_AMOUNT] = e
|
||||||
@@ -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"
|
||||||
@@ -612,7 +643,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 +660,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 +683,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 +710,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 +812,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,50 +821,62 @@ 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])
|
||||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
|
amount = "0"
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _validate(data, timestamp_to_check=False):
|
def _validate(data, timestamp_to_check=False):
|
||||||
|
|
||||||
for k, v in list(data.items()):
|
for k, v in list(data.items()):
|
||||||
@@ -874,6 +922,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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -356,7 +405,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 +427,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 +460,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 +494,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``.
|
||||||
|
|||||||
107
bal/core/util.py
107
bal/core/util.py
@@ -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
|
||||||
@@ -400,14 +401,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 +447,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 +494,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."""
|
||||||
|
|||||||
348
bal/core/will.py
348
bal/core/will.py
@@ -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,19 +443,25 @@ 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)
|
||||||
utxos = wallet.get_utxos()
|
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()
|
||||||
filtered_inputs = []
|
filtered_inputs = []
|
||||||
prevout_to_spend = []
|
prevout_to_spend = []
|
||||||
current_height = Util.get_current_height(wallet.network)
|
current_height = Util.get_current_height(wallet.network)
|
||||||
@@ -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,14 +596,22 @@ 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):
|
||||||
height = Will.check_tx_height(w.tx, wallet)
|
continue
|
||||||
if height < 0:
|
height = Will.check_tx_height(w.tx, wallet)
|
||||||
|
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)
|
||||||
else:
|
else:
|
||||||
w.set_status("CONFIRMED", True)
|
w.set_status("CONFIRMED", True)
|
||||||
|
|
||||||
# def reflect_to_children(treeitem):
|
# def reflect_to_children(treeitem):
|
||||||
# if not treeitem.get_status("VALID"):
|
# if not treeitem.get_status("VALID"):
|
||||||
@@ -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)
|
||||||
@@ -937,7 +1227,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 +1264,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 +1323,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 +1346,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 +1384,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:
|
||||||
|
|||||||
@@ -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,55 +289,48 @@ 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
|
||||||
try:
|
attempts = max_retries + 1
|
||||||
if method == "get":
|
for attempt in range(attempts):
|
||||||
response = Network.send_http_on_proxy(
|
try:
|
||||||
method,
|
if method == "get":
|
||||||
url,
|
response = Network.send_http_on_proxy(
|
||||||
params=data,
|
method,
|
||||||
headers=headers,
|
url,
|
||||||
on_finish=handle_response,
|
params=data,
|
||||||
timeout=timeout,
|
headers=headers,
|
||||||
)
|
on_finish=handle_response,
|
||||||
elif method == "post":
|
timeout=timeout,
|
||||||
response = Network.send_http_on_proxy(
|
)
|
||||||
method,
|
elif method == "post":
|
||||||
url,
|
response = Network.send_http_on_proxy(
|
||||||
body=data,
|
method,
|
||||||
headers=headers,
|
url,
|
||||||
on_finish=handle_response,
|
body=data,
|
||||||
timeout=timeout,
|
headers=headers,
|
||||||
)
|
on_finish=handle_response,
|
||||||
else:
|
timeout=timeout,
|
||||||
raise Exception(f"unexpected {method=!r}")
|
)
|
||||||
except TimeoutError:
|
else:
|
||||||
if count_reply < max_retries:
|
raise Exception(f"unexpected {method=!r}")
|
||||||
_logger.debug(
|
_logger.debug(f"--> {response}")
|
||||||
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
return response
|
||||||
)
|
except TimeoutError:
|
||||||
if retry_sleep:
|
if attempt < max_retries:
|
||||||
time.sleep(retry_sleep)
|
_logger.debug(
|
||||||
return Willexecutors.send_request(
|
f"timeout({attempt}) error: "
|
||||||
method,
|
f"retry in {retry_sleep} sec..."
|
||||||
url,
|
)
|
||||||
data,
|
if retry_sleep:
|
||||||
timeout=timeout,
|
time.sleep(retry_sleep)
|
||||||
handle_response=handle_response,
|
else:
|
||||||
count_reply=count_reply + 1,
|
_logger.debug(f"Too many timeouts: {attempt}")
|
||||||
max_retries=max_retries,
|
except Exception as e:
|
||||||
retry_sleep=retry_sleep,
|
raise e
|
||||||
)
|
return None
|
||||||
else:
|
|
||||||
_logger.debug(f"Too many timeouts: {count_reply}")
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
else:
|
|
||||||
_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):
|
||||||
willexecutor["url"] = url
|
address = w.get("address")
|
||||||
willexecutor["status"] = 200
|
if not isinstance(address, str) or not bitcoin.is_address(
|
||||||
willexecutor["base_fee"] = w["base_fee"]
|
address, net=constants.net
|
||||||
willexecutor["address"] = w["address"]
|
):
|
||||||
willexecutor["info"] = w["info"]
|
_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["status"] = 200
|
||||||
|
willexecutor["base_fee"] = base_fee
|
||||||
|
willexecutor["address"] = address
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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 .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.
|
||||||
@@ -79,7 +80,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:
|
||||||
@@ -192,30 +194,15 @@ class BalCalendar:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def ical_escape(text: str) -> str:
|
def ical_escape(text: str) -> str:
|
||||||
# escape per RFC5545: backslash, ; , newlines
|
# escape per RFC5545: backslash, ; , newlines
|
||||||
text = text.encode("utf-8")
|
|
||||||
text = (
|
text = (
|
||||||
text.replace(b"\\", b"\\\\")
|
text.replace("\\", "\\\\")
|
||||||
.replace(b";", b"\\;")
|
.replace(";", "\\;")
|
||||||
.replace(b",", b"\\,")
|
.replace(",", "\\,")
|
||||||
|
)
|
||||||
|
return "\r\n".join(
|
||||||
|
BalCalendar.fold_ical_line(line)
|
||||||
|
for line in text.split("\r\n")
|
||||||
)
|
)
|
||||||
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
|
@staticmethod
|
||||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 .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 .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget,
|
from .widgets import (
|
||||||
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget,
|
WillSettingsWidget,
|
||||||
WillSettingsWidget, WillWidget, basic_reminder_offsets,
|
WillWidget,
|
||||||
compute_reminder_offsets)
|
basic_reminder_offsets,
|
||||||
from .calendar import BalCalendar, BalCalendarButton
|
compute_reminder_offsets,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -49,7 +55,7 @@ class BalDialog(QDialog,MessageBoxMixin):
|
|||||||
self.setWindowTitle(title)
|
self.setWindowTitle(title)
|
||||||
# WindowModalDialog.__init__(self,parent)
|
# WindowModalDialog.__init__(self,parent)
|
||||||
self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon)))
|
self.setWindowIcon(read_QIcon_from_bytes(bal_plugin.read_file(icon)))
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self._stopping = True
|
self._stopping = True
|
||||||
# NOTE: we deliberately do NOT stop ``self.thread`` here.
|
# NOTE: we deliberately do NOT stop ``self.thread`` here.
|
||||||
@@ -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
|
||||||
@@ -1113,7 +1146,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 +1385,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 +1494,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,6 +1529,73 @@ 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, timedelta
|
||||||
@@ -1533,7 +1650,7 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
"BEGIN:VCALENDAR",
|
"BEGIN:VCALENDAR",
|
||||||
"VERSION:2.0",
|
"VERSION:2.0",
|
||||||
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||||||
f"{BalPlugin.__version__}",
|
f"{self.bal_window.bal_plugin.version}",
|
||||||
]
|
]
|
||||||
|
|
||||||
total = len(offsets)
|
total = len(offsets)
|
||||||
@@ -1876,10 +1993,18 @@ 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
|
||||||
self.threshold = bal_window.will_settings["real_threshold"]
|
# 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.bal_window = bal_window
|
self.bal_window = bal_window
|
||||||
Will.add_willtree(self.will)
|
Will.add_willtree(self.will)
|
||||||
@@ -1911,8 +2036,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 +2064,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,7 +2124,8 @@ class WillDetailDialog(BalDialog):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
self.will = self.bal_window.willitems
|
if not self._external_will:
|
||||||
|
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)
|
||||||
self.paint_scroll_area()
|
self.paint_scroll_area()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -105,12 +153,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
try:
|
try:
|
||||||
self.bal_window.set_heir(prior_name)
|
self.bal_window.set_heir(prior_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
self.update()
|
||||||
|
|
||||||
try:
|
|
||||||
self.bal_window.set_heir((edit_key,) + original)
|
|
||||||
except Exception:
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def delete_heirs(self, selected_keys):
|
def delete_heirs(self, selected_keys):
|
||||||
self.bal_window.delete_heirs(selected_keys)
|
self.bal_window.delete_heirs(selected_keys)
|
||||||
@@ -157,7 +200,17 @@ 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
|
||||||
labels[self.Columns.ADDRESS] = heir[0]
|
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.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),
|
||||||
except Exception:
|
)
|
||||||
pass
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ Contents:
|
|||||||
* WillWidget - single will-tx box
|
* WillWidget - single will-tx box
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
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:
|
||||||
|
from .window import BalWindow
|
||||||
|
|
||||||
|
|
||||||
def compute_reminder_offsets(days, count):
|
def compute_reminder_offsets(days, count):
|
||||||
@@ -690,7 +695,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
|
||||||
@@ -1153,7 +1158,7 @@ class WillSettingsWidget(QWidget):
|
|||||||
lines = [
|
lines = [
|
||||||
"BEGIN:VCALENDAR",
|
"BEGIN:VCALENDAR",
|
||||||
"VERSION:2.0",
|
"VERSION:2.0",
|
||||||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{self.bal_window.bal_plugin.version}",
|
||||||
]
|
]
|
||||||
|
|
||||||
# One separate VEVENT per reminder offset (its own date in the calendar).
|
# One separate VEVENT per reminder offset (its own date in the calendar).
|
||||||
@@ -1284,7 +1289,7 @@ class WillSettingsWidget(QWidget):
|
|||||||
"BEGIN:VCALENDAR",
|
"BEGIN:VCALENDAR",
|
||||||
"VERSION:2.0",
|
"VERSION:2.0",
|
||||||
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||||||
f"{BalPlugin.__version__}",
|
f"{self.bal_window.bal_plugin.version}",
|
||||||
]
|
]
|
||||||
|
|
||||||
total = len(offsets)
|
total = len(offsets)
|
||||||
@@ -1395,15 +1400,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 +1487,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 +1518,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 +1543,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 +1580,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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ import threading
|
|||||||
|
|
||||||
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:
|
||||||
@@ -200,15 +205,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_amount.setText(
|
heir_address.setText(addr)
|
||||||
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
|
if not is_op_return_address(addr):
|
||||||
)
|
heir_amount.setText(
|
||||||
|
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 +276,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 +298,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 +319,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 +355,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 +384,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 +399,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 +448,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)
|
||||||
@@ -529,9 +666,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 +683,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 +702,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 +818,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 +888,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 +903,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,8 +936,15 @@ 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 = [
|
||||||
|
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]
|
||||||
tx = copy.deepcopy(wi.tx)
|
tx = copy.deepcopy(wi.tx)
|
||||||
if wi.get_status("COMPLETE"):
|
if wi.get_status("COMPLETE"):
|
||||||
txs[txid] = tx
|
txs[txid] = tx
|
||||||
@@ -785,8 +956,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 +974,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 +1050,29 @@ 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)
|
willitems[txid].tx = copy.deepcopy(tx)
|
||||||
self.will[txid] = self.willitems[txid].to_dict()
|
if not external:
|
||||||
|
self.will[txid] = willitems[txid].to_dict()
|
||||||
try:
|
try:
|
||||||
self.will_list_widget.update()
|
Will.check_signatures(willitems, self.wallet)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
_logger.error(f"check_signatures after signing failed: {e}")
|
||||||
|
if not external:
|
||||||
|
try:
|
||||||
|
self.will_list_widget.update()
|
||||||
|
except Exception:
|
||||||
|
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,19 +1080,22 @@ 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):
|
||||||
self.will_list_widget.update()
|
if not external:
|
||||||
|
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")
|
||||||
self.show_warning(_("Some transaction was not broadcasted"))
|
self.show_warning(_("Some transaction was not broadcasted"))
|
||||||
@@ -909,7 +1106,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 +1120,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 +1162,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 +1191,189 @@ 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):
|
||||||
for wid in self.willitems:
|
if will is None:
|
||||||
self.willitems[wid].set_status("EXPORTED", True)
|
for wid in self.willitems:
|
||||||
self.will[wid] = self.willitems[wid].to_dict()
|
self.willitems[wid].set_status("EXPORTED", True)
|
||||||
write_json_file(path, self.will)
|
self.will[wid] = self.willitems[wid].to_dict()
|
||||||
|
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 = datetime.now().timestamp()
|
||||||
|
|
||||||
|
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):
|
||||||
try:
|
"""Import a will file and show it in a WillDetails window.
|
||||||
data = read_json_file(path)
|
|
||||||
willitems = {}
|
Unlike the "Merge" actions (which merge the file into the active
|
||||||
for k, v in data.items():
|
will), this is a read-only preview: the parsed will is shown in a
|
||||||
data[k]["tx"] = tx_from_any(v["tx"])
|
:class:`WillDetailDialog` and the live wallet state is never touched.
|
||||||
willitems[k] = WillItem(data[k], _id=k)
|
The dialog's Sign/Broadcast/Export/Invalidate buttons operate on the
|
||||||
self.update_will(willitems)
|
imported will only, and its Merge button merges the imported will
|
||||||
except Exception as e:
|
into the live one.
|
||||||
raise e
|
"""
|
||||||
# raise FileImportFailed(_("Invalid will file"))
|
imported = {}
|
||||||
|
|
||||||
|
def on_file(path):
|
||||||
|
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)
|
||||||
|
willitems = {}
|
||||||
|
for k, v in data.items():
|
||||||
|
data[k]["tx"] = tx_from_any(v["tx"])
|
||||||
|
willitems[k] = WillItem(data[k], _id=k)
|
||||||
|
return willitems
|
||||||
|
|
||||||
def check_transactions_task(self, will):
|
def check_transactions_task(self, will):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -1396,14 +1733,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 +1769,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
Documentation for the **BAL** open‑source 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 inheritance‑options page loads
|
(the styled manual works fully offline; the inheritance‑options 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).
|
||||||
|
|||||||
@@ -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 will‑executor(s)** | after **Broadcast** to executors |
|
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
||||||
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
||||||
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
|
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
|
||||||
@@ -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`.*
|
||||||
|
|||||||
@@ -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, `RAW‑1d` and it is, say, 5 p.m., the plugin will not
|
If you set, for example, `RAW‑1d` 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:
|
|||||||
|
|
||||||
## Will‑Executor service list
|
## Will‑Executor service list
|
||||||
|
|
||||||
This window opens from the Electrum menu, **Tools → Will‑executor**, and shows
|
This window opens from the Electrum menu, **Tools → Will‑Executors**, and shows
|
||||||
the official list of will‑executor servers.
|
the official list of will‑executor servers.
|
||||||
|
|
||||||
If you want to make changes — such as adding an additional will‑executor server —
|
If you want to make changes — such as adding an additional will‑executor server —
|
||||||
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each will‑executor 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 will‑executor | Azure‑green | `#73F3C8` |
|
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||||
| 4 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
| 4 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||||
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
| 5 | **Checked** | TX actually present in the will‑executor | 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 will‑executor | 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 will‑executor | Red | `#E83845` |
|
||||||
| 9 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||||
|
| 10 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||||
|
| 11 | **Updated** | TX re‑issued keeping the same locktime and heirs | Light violet | `#B266B2` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
285
make-release.sh
Executable file
285
make-release.sh
Executable 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
19
pyproject.toml
Normal 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
30
svatantrya.asc
Normal 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-----
|
||||||
@@ -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")
|
||||||
|
|||||||
2042
tests/karen7
2042
tests/karen7
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
792
tests/samanta7
Normal file
File diff suppressed because one or more lines are too long
@@ -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 = (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -347,44 +348,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
483
tests/test_core_will_invalidate.py
Normal file
483
tests/test_core_will_invalidate.py
Normal 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")
|
||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
from bal.gui.qt.widgets import compute_reminder_offsets
|
from bal.gui.qt.widgets import compute_reminder_offsets
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
397
tests/test_group_e_karen7_invalidate.py
Normal file
397
tests/test_group_e_karen7_invalidate.py
Normal 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
|
||||||
@@ -30,19 +30,18 @@ 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.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.calendar import BalCalendar
|
||||||
from bal.gui.qt.widgets import compute_reminder_offsets
|
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.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -275,7 +274,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 +549,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 +622,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
|
||||||
|
|||||||
@@ -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"]))
|
||||||
|
|||||||
@@ -24,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.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
|
from bal.gui.qt.widgets import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
|
||||||
basic_reminder_offsets)
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_all_future():
|
def test_basic_offsets_all_future():
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
|
|
||||||
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
|
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
|
||||||
|
|
||||||
|
|
||||||
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ Run:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timezone
|
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
|
# format_time
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
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 +24,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
|
||||||
@@ -47,19 +48,19 @@ def test_shown_cv_roundtrip():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
def test_check_alive_error_default():
|
||||||
err = C.CheckAliveError(1000000)
|
err = common.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)
|
err = common.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)
|
assert issubclass(common.CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,17 +69,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 +86,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))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
216
tests/test_gui_prepare_will_history.py
Normal file
216
tests/test_gui_prepare_will_history.py
Normal 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")
|
||||||
@@ -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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
150
tests/test_gui_will_menu.py
Normal file
150
tests/test_gui_will_menu.py
Normal 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")
|
||||||
@@ -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)
|
||||||
|
|||||||
599
tests/test_import_will_details.py
Normal file
599
tests/test_import_will_details.py
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
bal_plugin=SimpleNamespace(
|
||||||
|
HISTORY_LABEL=SimpleNamespace(
|
||||||
|
get=lambda: "BAL will history ({willexecutor})"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
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")
|
||||||
612
tests/test_no_willexecutor_karen7.py
Normal file
612
tests/test_no_willexecutor_karen7.py
Normal 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)
|
||||||
@@ -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()
|
||||||
|
|||||||
136
tests/test_settings_history_dialog.py
Normal file
136
tests/test_settings_history_dialog.py
Normal 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")
|
||||||
116
tests/test_version_source.py
Normal file
116
tests/test_version_source.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user