Compare commits
1 Commits
main
...
bitcoinaft
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b196e774c |
29
.gitignore
vendored
29
.gitignore
vendored
@@ -4,32 +4,3 @@
|
|||||||
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
76
AGENTS.md
@@ -1,76 +0,0 @@
|
|||||||
# 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,242 +2329,3 @@ 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.
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 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 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
|
**Electrum 4.7.2** 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 the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`).
|
- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,17 +55,16 @@ 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 <- package docstring (no version here anymore)
|
__init__.py <- __version__ (one of 4 version files)
|
||||||
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
|
VERSION <- plain-text version (one of 4 version files)
|
||||||
qt.py <- zipimport shim used when loaded as an external ZIP plugin
|
manifest.json <- plugin manifest, "version" field (one of 4)
|
||||||
core/
|
core/
|
||||||
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4)
|
||||||
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 (is_selected / is_valid,
|
willexecutors.py <- remote will-executor services handling.
|
||||||
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
|
||||||
@@ -73,15 +72,12 @@ 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 <- BalWindow, the per-wallet controller (build_will, check_will,
|
window.py <- BalWalletWindow (build_will, check_will, get_transactions).
|
||||||
get_transactions, merge_will, on_close, menubar wiring).
|
lists.py, calendar.py, theme.py, window_utils.py, ...
|
||||||
plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
|
tests/ <- pytest suite (see run command below).
|
||||||
lists.py, calendar.py, theme.py (status colours), window_utils.py
|
electrum-src/ <- a copy of Electrum source, used ONLY for tests
|
||||||
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
(PYTHONPATH=electrum-src). NOT shipped in the ZIP.
|
||||||
tests/ <- standalone test scripts (see Section 3).
|
build_zip.py <- builds the shippable ZIP (37 files).
|
||||||
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.
|
||||||
@@ -91,72 +87,46 @@ HANDOFF.md <- this file.
|
|||||||
|
|
||||||
## 3. How to build, test and lint
|
## 3. How to build, test and lint
|
||||||
|
|
||||||
Two separate venvs — using the wrong one is the #1 mistake:
|
Run everything from `/home/user/webapp`.
|
||||||
|
|
||||||
- **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
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||||
python3 tests/test_core_heirs.py # core, no Qt needed
|
tests/test_core_*.py tests/test_gui_*.py \
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
/home/steal/devel/bal/bal-electrum-plugin/venv/bin/ruff check <files> \
|
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
||||||
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
|
| grep -vE "F401|F403|F405|F841"
|
||||||
```
|
```
|
||||||
Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
|
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via
|
||||||
`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.zip (deterministic, prints sha256; 36 files)
|
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bump version — ONE file only (single source of truth):**
|
**Bump version — there are FOUR files, keep them in sync:**
|
||||||
```
|
```
|
||||||
|
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)
|
||||||
@@ -255,49 +225,25 @@ See Section 5 for details.
|
|||||||
|
|
||||||
## 5. Git / delivery workflow
|
## 5. Git / delivery workflow
|
||||||
|
|
||||||
- **Branch:** work directly on `main` (no PR flow anymore). Push straight to
|
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`.
|
||||||
`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 pushing: check `git status`/`git diff`, stage only the intended files
|
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve
|
||||||
(never secrets), commit with a concise message, then push to `origin/main`.
|
conflicts preferring remote `main` unless a local change is essential,
|
||||||
|
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 **Gitea Releases** using `make-release.sh`.
|
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`).
|
||||||
- **Release process** (`make-release.sh`):
|
The newest release is the "Latest" and is the owner's convenient download.
|
||||||
1. Version bump in `bal/manifest.json` (single source of truth)
|
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can
|
||||||
2. Clean `__pycache__` and `.pyc` files
|
download them directly from chat.
|
||||||
3. Run full test suite
|
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token",
|
||||||
4. Lint with ruff (skip if not installed)
|
re-run the GitHub environment setup, then retry.
|
||||||
5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
|
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section +
|
||||||
6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
|
translation), **#15** (v0.4.8). All merged into `main`.
|
||||||
7. Export public key as `svatantrya.asc`
|
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`);
|
||||||
8. SHA-256 checksum
|
v0.4.7 kept in history.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -326,26 +272,6 @@ See Section 5 for details.
|
|||||||
(#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
|
||||||
@@ -366,16 +292,13 @@ See Section 5 for details.
|
|||||||
## 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, and the `"version"` field of `bal/manifest.json`.
|
2. Confirm the environment: `git status`, current branch, `bal/VERSION`.
|
||||||
3. Run the offline test files (Section 3) — expect the offline subset to pass
|
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8).
|
||||||
(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) directly on `main`,
|
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`,
|
||||||
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub
|
||||||
**Release** with the ZIP + signatures attached (it becomes the owner's
|
**Release** with the ZIP attached (it becomes the owner's "Latest" download).
|
||||||
"Latest" download). Always give the owner the Release URL.
|
Always give the owner the PR URL and the Release URL.
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -31,26 +31,17 @@ 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 README.md
|
├── icons/ wallet_util/ LICENSE VERSION 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 or 4.8.0** — the plugin detects which wallet-DB
|
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`,
|
||||||
registration API is available (`json_db.register_dict` on 4.7.2,
|
which this plugin relies on. Newer versions removed it.
|
||||||
`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
|
||||||
@@ -85,17 +76,12 @@ 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 cases as follows (triggered when you press
|
The plugin handles the two cases as follows (triggered when you press
|
||||||
**Prepare** on the **WILL** tab):
|
**Tools → Prepare**):
|
||||||
|
|
||||||
* **Anticipate** (new delivery time *earlier* than the signed locktime, still
|
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
|
||||||
in the future): a plain **rebuild** — the transactions are re-created with
|
will is treated as expired and you are asked to **invalidate** the old
|
||||||
the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
|
transaction on-chain, then rebuild.
|
||||||
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
|
||||||
@@ -126,15 +112,14 @@ 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 python3 tests/smoke_test.py electrum.plugins.bal
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||||
|
python3 tests/smoke_test.py electrum.plugins.bal
|
||||||
|
|
||||||
# external-zip loading regression (run after build_zip.py)
|
# external-zip loading regression
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||||
|
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 the
|
too early), then lets you rebuild and re-send the new one via
|
||||||
**Prepare** button on the **WILL** tab.
|
**Tools → Prepare**.
|
||||||
- **"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
bal/VERSION
Normal file
1
bal/VERSION
Normal file
@@ -0,0 +1 @@
|
|||||||
|
0.6.0
|
||||||
@@ -36,7 +36,4 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# The plugin version is NOT defined here. It lives only in ``bal/manifest.json``
|
__version__ = "0.5.18"
|
||||||
# (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.
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core.checkalive
|
|
||||||
===================
|
|
||||||
|
|
||||||
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
|
|
||||||
against which every will-validity check is evaluated, and the BASIC/ADVANCED
|
|
||||||
mode rules that decide it.
|
|
||||||
|
|
||||||
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
|
|
||||||
postpone/invalidate flow; the decision that it *should* be raised lives here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
|
|
||||||
class CheckAliveError(Exception):
|
|
||||||
"""Raised when the "check alive" date is in the past."""
|
|
||||||
|
|
||||||
def __init__(self, timestamp_to_check):
|
|
||||||
self.timestamp_to_check = timestamp_to_check
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Check alive expired please update it: {}".format(
|
|
||||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_date_to_check(
|
|
||||||
is_basic_mode: bool,
|
|
||||||
will_settings: Any,
|
|
||||||
now: float | None = None,
|
|
||||||
built_locktime: float | int | None = None,
|
|
||||||
) -> float:
|
|
||||||
"""Return the reference timestamp for every will-validity check.
|
|
||||||
|
|
||||||
``date_to_check`` is the single reference timestamp that EVERY downstream
|
|
||||||
check reads: the build filter, the heir count, ``check_will_expired``,
|
|
||||||
``check_amounts`` and the locktime-vs-threshold guard.
|
|
||||||
|
|
||||||
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
|
|
||||||
govern those checks. ``date_to_check`` is set to *now*: every check is
|
|
||||||
evaluated against the current moment (the Check Alive effectively does not
|
|
||||||
exist) while the delivery locktime is still fully enforced.
|
|
||||||
* ADVANCED mode: the user-controlled stored threshold is used as-is. An
|
|
||||||
ABSOLUTE threshold is returned unchanged; a RELATIVE one (``"30d"``/``"1y"``)
|
|
||||||
means "N days BEFORE the delivery date" and is resolved against the stored
|
|
||||||
locktime (matching the date the settings widget displays), so it stays in
|
|
||||||
lockstep with the built transactions instead of drifting with the clock.
|
|
||||||
|
|
||||||
A RELATIVE stored locktime is resolved against the frozen delivery date of
|
|
||||||
the built will (``built_locktime``, the locktime inside the signed tx) when
|
|
||||||
one exists: the will's real delivery date is authoritative, and resolving
|
|
||||||
the relative locktime from *now* would drift ``date_to_check`` past the
|
|
||||||
frozen tx locktime so an unchanged will wrongly reads as expired (asking to
|
|
||||||
invalidate) every day. Without a built will the legacy forward-from-now
|
|
||||||
resolution is kept.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
|
|
||||||
will_settings: the per-wallet settings dict (``"threshold"`` and
|
|
||||||
``"locktime"`` keys).
|
|
||||||
now: overridable clock for tests; defaults to ``datetime.now()``.
|
|
||||||
built_locktime: the absolute locktime frozen inside the built will's
|
|
||||||
transactions (``None`` when there is no built will yet).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The reference timestamp (float, UNIX seconds).
|
|
||||||
"""
|
|
||||||
if is_basic_mode:
|
|
||||||
return (now if now is not None else datetime.now().timestamp())
|
|
||||||
|
|
||||||
threshold = BalTimestamp(will_settings["threshold"])
|
|
||||||
# A RELATIVE threshold ("30d"/"1y") means "N days BEFORE the delivery":
|
|
||||||
# the settings widget resolves it as real_threshold = locktime - N days.
|
|
||||||
# Resolving it FORWARD from now (BalTimestamp.to_timestamp) turns
|
|
||||||
# date_to_check into a moving target that disagrees with the fixed
|
|
||||||
# locktime of the built transactions (and with the date shown in the UI),
|
|
||||||
# which can wrongly mark the will as expired/postponed. Resolve it
|
|
||||||
# against the delivery date instead.
|
|
||||||
if threshold.unit is not None:
|
|
||||||
locktime_raw = will_settings.get("locktime")
|
|
||||||
if locktime_raw is None:
|
|
||||||
# No delivery reference to anchor to: fall back to the legacy
|
|
||||||
# forward-from-now resolution.
|
|
||||||
return threshold.to_timestamp()
|
|
||||||
locktime_dt = BalTimestamp(locktime_raw).to_date(now)
|
|
||||||
# A RELATIVE stored locktime ("2y") is itself a moving target; when a
|
|
||||||
# will has already been built, its frozen delivery date (the tx
|
|
||||||
# locktime) is the authoritative anchor (see docstring).
|
|
||||||
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
|
||||||
locktime_dt = BalTimestamp(int(built_locktime)).to_date(now)
|
|
||||||
return threshold.to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
return threshold.to_timestamp()
|
|
||||||
|
|
||||||
|
|
||||||
def check_alive_expired(
|
|
||||||
is_basic_mode: bool, date_to_check: float, now: float | None = None
|
|
||||||
) -> bool:
|
|
||||||
"""True when the Check Alive guard should fire (``CheckAliveError``).
|
|
||||||
|
|
||||||
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
|
|
||||||
by construction, so a passed check-alive date must never force a postpone or
|
|
||||||
rewrite of the will.
|
|
||||||
"""
|
|
||||||
if is_basic_mode:
|
|
||||||
return False
|
|
||||||
current = now if now is not None else datetime.now().timestamp()
|
|
||||||
return date_to_check < current
|
|
||||||
@@ -21,8 +21,6 @@ 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
|
||||||
@@ -33,7 +31,6 @@ from typing import (
|
|||||||
Dict,
|
Dict,
|
||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
cast,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import dns
|
import dns
|
||||||
@@ -68,19 +65,6 @@ 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.
|
||||||
@@ -91,8 +75,6 @@ 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
|
||||||
@@ -104,27 +86,6 @@ 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)
|
||||||
@@ -171,14 +132,6 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
if is_op_return_address(heir[HEIR_ADDRESS]):
|
|
||||||
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
|
||||||
op_return_script = create_op_return_script(data_hex)
|
|
||||||
outputs.append(
|
|
||||||
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
|
||||||
)
|
|
||||||
description += f"{name}\n"
|
|
||||||
else:
|
|
||||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
outputs.append(
|
outputs.append(
|
||||||
PartialTxOutput.from_address_and_value(
|
PartialTxOutput.from_address_and_value(
|
||||||
@@ -221,7 +174,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 _ in range(0, 100):
|
for i in range(0, 100):
|
||||||
random.shuffle(outputs)
|
random.shuffle(outputs)
|
||||||
|
|
||||||
#op_return_text = "Hello Bal!"
|
#op_return_text = "Hello Bal!"
|
||||||
@@ -277,7 +230,6 @@ 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 = {}
|
||||||
@@ -297,7 +249,7 @@ def invalidate_inheritance_transactions(wallet):
|
|||||||
del dtxs[txid]
|
del dtxs[txid]
|
||||||
|
|
||||||
utxos = {}
|
utxos = {}
|
||||||
for _, tx in dtxs.items():
|
for txid, 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]))
|
||||||
@@ -312,6 +264,39 @@ 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():
|
||||||
@@ -415,9 +400,6 @@ 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
|
||||||
@@ -469,12 +451,6 @@ 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])
|
||||||
@@ -502,8 +478,7 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def prepare_lists(
|
def prepare_lists(
|
||||||
self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
|
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
|
||||||
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)
|
||||||
@@ -518,12 +493,8 @@ 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: list = [None] * 4
|
h = [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
|
||||||
@@ -615,8 +586,6 @@ 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"
|
||||||
@@ -630,19 +599,7 @@ class Heirs(dict, Logger):
|
|||||||
def buildTransactions(
|
def buildTransactions(
|
||||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||||
):
|
):
|
||||||
_before = list(self.keys())
|
Heirs._validate(self)
|
||||||
Heirs._validate(self, persist=False)
|
|
||||||
_removed = [k for k in _before if k not in self]
|
|
||||||
if _removed:
|
|
||||||
# The build skips invalid heirs, but they are only dropped in memory
|
|
||||||
# (persist=False): the wallet still keeps them, so the user can fix
|
|
||||||
# or remove them deliberately instead of losing them silently.
|
|
||||||
_logger.warning(
|
|
||||||
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
|
|
||||||
"not removed): %s",
|
|
||||||
len(_removed),
|
|
||||||
", ".join(_removed),
|
|
||||||
)
|
|
||||||
if len(self) <= 0:
|
if len(self) <= 0:
|
||||||
_logger.info("while building transactions there was no heirs")
|
_logger.info("while building transactions there was no heirs")
|
||||||
return
|
return
|
||||||
@@ -655,7 +612,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:
|
if utxo.value_sats() > 0 * tx_fees:
|
||||||
balance += utxo.value_sats()
|
balance += utxo.value_sats()
|
||||||
len_utxo_set += 1
|
len_utxo_set += 1
|
||||||
available_utxos.append(utxo)
|
available_utxos.append(utxo)
|
||||||
@@ -672,19 +629,18 @@ 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) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
|
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < 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 = None
|
url = willexecutor = False
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
fees = {}
|
fees = {}
|
||||||
i = 0
|
i = 0
|
||||||
txs = {}
|
|
||||||
while i < 10:
|
while i < 10:
|
||||||
txs = {}
|
txs = {}
|
||||||
redo = False
|
redo = False
|
||||||
@@ -695,15 +651,11 @@ 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(
|
||||||
@@ -722,7 +674,7 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
raise e
|
||||||
total_fees = 0
|
total_fees = 0
|
||||||
total_fees_real = 0
|
total_fees_real = 0
|
||||||
total_in = 0
|
total_in = 0
|
||||||
@@ -824,7 +776,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 = _query_txt_records(url)
|
records, validated = dnssec.query(url, dns.rdatatype.TXT)
|
||||||
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
|
||||||
@@ -833,78 +785,60 @@ 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) -> Optional[str]:
|
def find_regex(haystack, needle):
|
||||||
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}") from e
|
raise AmountNotValid(f"amount not properly formatted, {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}") from e
|
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
||||||
return locktime
|
return locktime
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_heir(k, v, timestamp_to_check=False):
|
def validate_heir(k, v, timestamp_to_check=False):
|
||||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||||
if is_op_return_address(v[HEIR_ADDRESS]):
|
|
||||||
amount = "0"
|
|
||||||
else:
|
|
||||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||||
return (address, amount, locktime)
|
return (address, amount, locktime)
|
||||||
|
|
||||||
@staticmethod
|
def _validate(data, timestamp_to_check=False):
|
||||||
def _validate(data, timestamp_to_check=False, persist=True):
|
|
||||||
|
|
||||||
for k, v in list(data.items()):
|
for k, v in list(data.items()):
|
||||||
if k == "heirs":
|
if k == "heirs":
|
||||||
return Heirs._validate(v, timestamp_to_check, persist)
|
return Heirs._validate(v, timestamp_to_check)
|
||||||
try:
|
try:
|
||||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.info(f"exception heir removed {e}")
|
_logger.info(f"exception heir removed {e}")
|
||||||
if persist:
|
|
||||||
data.pop(k)
|
data.pop(k)
|
||||||
else:
|
|
||||||
# Drop the invalid heir in memory only, so the overridden
|
|
||||||
# Heirs.pop (which calls save()) is not triggered: a build
|
|
||||||
# must not silently delete heirs from the wallet.
|
|
||||||
dict.pop(data, k)
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -940,19 +874,6 @@ 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
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core.input_rules
|
|
||||||
====================
|
|
||||||
|
|
||||||
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
|
|
||||||
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
|
|
||||||
amount field normalisation.
|
|
||||||
|
|
||||||
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
|
|
||||||
Keeping them here makes them testable without Qt and lets any GUI front-end
|
|
||||||
reuse the exact same parsing rules.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"NLOCKTIME_BLOCKHEIGHT_MAX",
|
|
||||||
"NLOCKTIME_MAX",
|
|
||||||
"NLOCKTIME_MIN",
|
|
||||||
"LockTimeEditor",
|
|
||||||
"normalize_locktime_raw_text",
|
|
||||||
"normalize_perc_amount_text",
|
|
||||||
"parse_perc_amount",
|
|
||||||
"replace_dy_suffixes",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class LockTimeEditor:
|
|
||||||
"""Acceptance bounds shared by the RAW and Date locktime editors.
|
|
||||||
|
|
||||||
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
|
|
||||||
live here so they can be tested and reused without Qt. Widget subclasses
|
|
||||||
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
|
|
||||||
"""
|
|
||||||
|
|
||||||
min_allowed_value = NLOCKTIME_MIN
|
|
||||||
max_allowed_value = NLOCKTIME_MAX
|
|
||||||
alarm = None
|
|
||||||
|
|
||||||
def get_value(self) -> Optional[int]:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def set_value(self, x: Any, force=True) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_acceptable_locktime(cls, x: Any) -> bool:
|
|
||||||
"""True when ``x`` (string or int) is within the allowed locktime bounds.
|
|
||||||
|
|
||||||
An empty/falsy value is accepted (the field is not yet filled in).
|
|
||||||
"""
|
|
||||||
if not x: # e.g. empty string
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
x = int(x)
|
|
||||||
except Exception as _e:
|
|
||||||
return False
|
|
||||||
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_max_allowed_timestamp() -> int:
|
|
||||||
"""Highest locktime timestamp accepted on this platform.
|
|
||||||
|
|
||||||
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
|
|
||||||
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
|
|
||||||
"""
|
|
||||||
ts = NLOCKTIME_MAX
|
|
||||||
# Test if this value is within the valid timestamp limits (which is
|
|
||||||
# platform-dependent). see #6170
|
|
||||||
try:
|
|
||||||
datetime.fromtimestamp(ts)
|
|
||||||
except (OSError, OverflowError):
|
|
||||||
ts = 2**31 - 1 # INT32_MAX
|
|
||||||
datetime.fromtimestamp(ts) # test if raises
|
|
||||||
return ts
|
|
||||||
|
|
||||||
|
|
||||||
def replace_dy_suffixes(text: str) -> str:
|
|
||||||
"""Strip the relative-time suffixes (d/y) from ``text``.
|
|
||||||
|
|
||||||
Only days ("d") and years ("y") are supported. The block-height suffix
|
|
||||||
("b") was removed (A1): locktimes are always timestamps now.
|
|
||||||
"""
|
|
||||||
return str(text).replace("d", "").replace("y", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
|
|
||||||
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
|
|
||||||
|
|
||||||
When the character just before ``pos`` equals ``appendix``, the text is
|
|
||||||
re-normalised so only one suffix remains.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
charpos = pos - 1
|
|
||||||
charpos = max(0, charpos)
|
|
||||||
charpos = min(len(s) - 1, charpos)
|
|
||||||
if appendix == s[charpos]:
|
|
||||||
s = replace_dy_suffixes(s) + appendix
|
|
||||||
pos = charpos
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return pos, s
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_locktime_raw_text(
|
|
||||||
text: str, pos: int
|
|
||||||
) -> Tuple[str, bool, bool, int]:
|
|
||||||
"""Sanitise the RAW locktime field text.
|
|
||||||
|
|
||||||
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
|
|
||||||
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: the raw field text.
|
|
||||||
pos: the cursor position within ``text``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
|
||||||
text and ``new_pos`` the adjusted cursor position.
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
|
||||||
chars = "0123456789dy"
|
|
||||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
|
||||||
s = "".join([i for i in text if i in chars])
|
|
||||||
isdays = False
|
|
||||||
isyears = False
|
|
||||||
|
|
||||||
pos, s = _checkbdy(s, pos, "d")
|
|
||||||
pos, s = _checkbdy(s, pos, "y")
|
|
||||||
|
|
||||||
if "d" in s:
|
|
||||||
isdays = True
|
|
||||||
if "y" in s:
|
|
||||||
isyears = True
|
|
||||||
|
|
||||||
if isdays:
|
|
||||||
s = replace_dy_suffixes(s) + "d"
|
|
||||||
if isyears:
|
|
||||||
s = replace_dy_suffixes(s) + "y"
|
|
||||||
return s, isdays, isyears, pos
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
|
|
||||||
"""Sanitise the amount-or-percentage field text.
|
|
||||||
|
|
||||||
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
|
|
||||||
as a percentage (``is_perc``). At most 8 decimal digits after the point.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: the raw field text.
|
|
||||||
decimal_point: the decimal separator character (``electrum`` uses
|
|
||||||
``DECIMAL_POINT``, locale dependent).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(clean, is_perc)``.
|
|
||||||
"""
|
|
||||||
text = text.strip()
|
|
||||||
chars = "0123456789%"
|
|
||||||
chars += decimal_point
|
|
||||||
|
|
||||||
s = "".join([i for i in text if i in chars])
|
|
||||||
|
|
||||||
if "%" in s:
|
|
||||||
is_perc = True
|
|
||||||
s = s.replace("%", "")
|
|
||||||
else:
|
|
||||||
is_perc = False
|
|
||||||
|
|
||||||
if decimal_point in s:
|
|
||||||
p = s.find(decimal_point)
|
|
||||||
s = s.replace(decimal_point, "")
|
|
||||||
s = s[:p] + decimal_point + s[p : p + 8]
|
|
||||||
if is_perc:
|
|
||||||
s += "%"
|
|
||||||
|
|
||||||
return s, is_perc
|
|
||||||
|
|
||||||
|
|
||||||
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
|
|
||||||
"""Parse an amount-or-percentage field text into a numeric value.
|
|
||||||
|
|
||||||
Returns ``None`` when the text cannot be parsed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
text = text.replace(decimal_point, ".")
|
|
||||||
text = text.replace("%", "")
|
|
||||||
return Decimal(text)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
@@ -21,7 +21,6 @@ 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
|
||||||
@@ -34,46 +33,6 @@ 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
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -108,7 +67,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 # pyright: ignore[reportMissingImports]
|
from electrum.stored_dict import register_name as _electrum_register_name
|
||||||
|
|
||||||
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)."""
|
||||||
@@ -118,7 +77,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) # pyright: ignore[reportAttributeAccessIssue]
|
json_db.register_dict(name, method, _type)
|
||||||
|
|
||||||
|
|
||||||
_register_will_dict("heirs", tuple)
|
_register_will_dict("heirs", tuple)
|
||||||
@@ -161,6 +120,9 @@ 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",
|
||||||
@@ -176,11 +138,18 @@ 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):
|
||||||
"""Plugin version, read from ``bal/manifest.json`` (single source of
|
"""Return the plugin version, read once from the ``VERSION`` file."""
|
||||||
truth). See :func:`get_version`."""
|
if not self._version:
|
||||||
return get_version()
|
try:
|
||||||
|
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__)
|
||||||
@@ -228,21 +197,6 @@ 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
|
||||||
@@ -274,9 +228,6 @@ 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)
|
||||||
@@ -306,10 +257,7 @@ class BalPlugin(BasePlugin):
|
|||||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default will-executor servers, keyed by network. These addresses are
|
# Default will-executor servers, keyed by network.
|
||||||
# the ones currently reported by each server's <chain>/info endpoint and
|
|
||||||
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
|
|
||||||
# (bcrt1...) addresses, which are invalid on those networks.
|
|
||||||
self.WILLEXECUTORS = BalConfig(
|
self.WILLEXECUTORS = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_willexecutors",
|
"bal_willexecutors",
|
||||||
@@ -328,7 +276,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -337,7 +285,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
|
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -408,7 +356,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"):
|
||||||
@@ -430,7 +378,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: dict[str, float] = {"baltx_fees": 20}
|
will_settings = {"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
|
||||||
|
|
||||||
@@ -463,12 +411,10 @@ class BalTimestamp:
|
|||||||
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value: int
|
value = None
|
||||||
unit: str | None
|
unit = 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])
|
||||||
@@ -497,14 +443,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``.
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
"""
|
|
||||||
bal.core.reminders
|
|
||||||
==================
|
|
||||||
|
|
||||||
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
|
|
||||||
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
|
|
||||||
iCalendar (.ics) document.
|
|
||||||
|
|
||||||
Everything in this module is stdlib-only, so it can be imported and tested
|
|
||||||
without Electrum or Qt (e.g. in the lint venv).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
def compute_reminder_offsets(days, count):
|
|
||||||
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
|
||||||
|
|
||||||
Group D / D1. The reminders are spread uniformly across the check-alive
|
|
||||||
period and always fall *before* the delivery deadline, i.e. every returned
|
|
||||||
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
* ``count`` is the requested number of reminders (the settings dialog
|
|
||||||
caps it at 5, default 3).
|
|
||||||
* at most ONE reminder per available day: the effective number is
|
|
||||||
``min(count, days)``;
|
|
||||||
* with ``days`` available days, offsets are chosen as evenly spaced
|
|
||||||
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
|
||||||
= the first day of the period), de-duplicated and returned sorted
|
|
||||||
descending (earliest reminder first).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: number of whole days between check-alive and the deadline.
|
|
||||||
count: requested number of reminders.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
|
||||||
``days=30, count=3``. Empty if there is no room for any reminder.
|
|
||||||
"""
|
|
||||||
# No room for any reminder (deadline today or already passed).
|
|
||||||
if days < 1 or count < 1:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Never more reminders than available days (one per day at most).
|
|
||||||
effective = min(int(count), int(days))
|
|
||||||
|
|
||||||
# A single reminder: put it one day before the deadline.
|
|
||||||
if effective == 1:
|
|
||||||
return [1]
|
|
||||||
|
|
||||||
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
|
||||||
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
|
||||||
# This places the first reminder at the start of the period (offset ~days)
|
|
||||||
# and the last one one day before the deadline (offset 1).
|
|
||||||
offsets = set()
|
|
||||||
for i in range(effective):
|
|
||||||
frac = i / (effective - 1) # 0.0 .. 1.0
|
|
||||||
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
|
||||||
offset = round(days - frac * (days - 1))
|
|
||||||
offset = max(1, min(days, offset))
|
|
||||||
offsets.add(offset)
|
|
||||||
|
|
||||||
# Sorted descending: earliest reminder (largest offset) first.
|
|
||||||
return sorted(offsets, reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
|
|
||||||
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
|
|
||||||
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
|
|
||||||
# 1 day before the inheritance delivery date.
|
|
||||||
BASIC_REMINDER_OFFSETS = (30, 10, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def basic_reminder_offsets(days_to_deadline):
|
|
||||||
"""Return the BASIC-mode reminder offsets that still fall in the future.
|
|
||||||
|
|
||||||
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
|
|
||||||
1 day before the delivery date). Any offset that would land in the past is
|
|
||||||
dropped, because a reminder before "today" is useless: if the delivery date
|
|
||||||
is only ``days_to_deadline`` days away, only the offsets that are ``<=
|
|
||||||
days_to_deadline`` are kept.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days_to_deadline: whole days from now until the delivery date.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of integer day-offsets (each ``>= 1``), sorted as in
|
|
||||||
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
|
|
||||||
when the delivery date is less than one day away.
|
|
||||||
"""
|
|
||||||
horizon = max(int(days_to_deadline), 0)
|
|
||||||
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
|
|
||||||
|
|
||||||
|
|
||||||
def format_time(time) -> str:
|
|
||||||
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
|
|
||||||
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
||||||
|
|
||||||
|
|
||||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
|
||||||
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
|
|
||||||
multi-byte UTF-8 characters. Continuation lines start with a space."""
|
|
||||||
encoded = line.encode("utf-8")
|
|
||||||
parts = []
|
|
||||||
while len(encoded) > limit:
|
|
||||||
# cut without splitting a UTF-8 continuation byte
|
|
||||||
cut = limit
|
|
||||||
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
|
|
||||||
cut -= 1
|
|
||||||
parts.append(encoded[:cut].decode("utf-8"))
|
|
||||||
encoded = encoded[cut:]
|
|
||||||
parts.append(encoded.decode("utf-8"))
|
|
||||||
return "\r\n ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def ical_escape(text: str) -> str:
|
|
||||||
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
|
|
||||||
text = (
|
|
||||||
text.replace("\\", "\\\\")
|
|
||||||
.replace(";", "\\;")
|
|
||||||
.replace(",", "\\,")
|
|
||||||
)
|
|
||||||
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
|
|
||||||
|
|
||||||
|
|
||||||
def write_temp_ics(content: str) -> str:
|
|
||||||
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
|
|
||||||
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
|
|
||||||
with os.fdopen(fd, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def build_ics_reminders(
|
|
||||||
*,
|
|
||||||
locktime: datetime,
|
|
||||||
basic_mode: bool,
|
|
||||||
description: str,
|
|
||||||
summary: str,
|
|
||||||
wallet_name: str,
|
|
||||||
heirs_details: str,
|
|
||||||
version: str,
|
|
||||||
num_reminders: int = 3,
|
|
||||||
now: Optional[datetime] = None,
|
|
||||||
threshold: Optional[datetime] = None,
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Build the ``.ics`` content with one VEVENT per reminder date.
|
|
||||||
|
|
||||||
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
|
|
||||||
user sees several distinct appointments in their calendar. The reminder
|
|
||||||
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
|
|
||||||
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
|
|
||||||
period).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
locktime: the delivery deadline (datetime).
|
|
||||||
basic_mode: use the fixed BASIC offsets instead of spreading over the
|
|
||||||
check-alive period.
|
|
||||||
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
|
|
||||||
``$heirs_complete`` placeholders are substituted and escaped.
|
|
||||||
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
|
|
||||||
wallet_name: label used in the UID and template substitutions.
|
|
||||||
heirs_details: pre-formatted heir list injected into ``description``.
|
|
||||||
version: plugin version, embedded in the PRODID line.
|
|
||||||
num_reminders: requested reminder count (ADVANCED mode only).
|
|
||||||
now: "today" reference; defaults to ``datetime.now()``.
|
|
||||||
threshold: check-alive date (ADVANCED mode only; required there).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The ``.ics`` content string, or ``None`` when no reminder falls in the
|
|
||||||
future (the delivery date is too close or already passed) so the caller
|
|
||||||
can show a warning instead of producing an empty-looking file.
|
|
||||||
"""
|
|
||||||
now = now if now is not None else datetime.now()
|
|
||||||
|
|
||||||
if basic_mode:
|
|
||||||
days_to_deadline = (locktime - now).days
|
|
||||||
offsets = basic_reminder_offsets(days_to_deadline)
|
|
||||||
else:
|
|
||||||
if threshold is None:
|
|
||||||
raise ValueError("threshold is required in ADVANCED mode")
|
|
||||||
days = (locktime - threshold).days
|
|
||||||
offsets = compute_reminder_offsets(days, num_reminders)
|
|
||||||
|
|
||||||
# ToDo #2: no future reminder means there are no events to write. Return
|
|
||||||
# None so the caller shows a clear warning instead of an empty .ics file.
|
|
||||||
if not offsets:
|
|
||||||
return None
|
|
||||||
|
|
||||||
event_description = ical_escape(
|
|
||||||
f"{description}"
|
|
||||||
.replace("$wallet_name", str(wallet_name))
|
|
||||||
.replace("$heirs_complete", heirs_details)
|
|
||||||
)
|
|
||||||
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"BEGIN:VCALENDAR",
|
|
||||||
"VERSION:2.0",
|
|
||||||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
|
|
||||||
]
|
|
||||||
|
|
||||||
# One separate VEVENT per reminder offset (its own date in the calendar).
|
|
||||||
total = len(offsets)
|
|
||||||
for idx, offset in enumerate(offsets, start=1):
|
|
||||||
# The visible date of this event: "offset" days before the deadline.
|
|
||||||
event_dt = format_time(locktime - timedelta(days=offset))
|
|
||||||
# Suffix the summary so the N events are easy to tell apart.
|
|
||||||
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
|
||||||
lines.extend([
|
|
||||||
"BEGIN:VEVENT",
|
|
||||||
# Offset in the UID keeps each event unique (no merging).
|
|
||||||
f"UID:bal-{str(wallet_name)}-{offset}d",
|
|
||||||
f"DTSTAMP:{format_time(now)}",
|
|
||||||
f"DTSTART:{event_dt}",
|
|
||||||
f"DTEND:{event_dt}",
|
|
||||||
f"SUMMARY:{event_summary}",
|
|
||||||
f"DESCRIPTION:{event_description}",
|
|
||||||
"END:VEVENT",
|
|
||||||
])
|
|
||||||
|
|
||||||
lines.append("END:VCALENDAR")
|
|
||||||
lines = [s.rstrip("\r\n") for s in lines]
|
|
||||||
return "\r\n".join(lines) + "\r\n"
|
|
||||||
173
bal/core/util.py
173
bal/core/util.py
@@ -20,7 +20,6 @@ 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
|
||||||
@@ -131,72 +130,6 @@ class Util:
|
|||||||
+ days * 60 * 60 * 24
|
+ days * 60 * 60 * 24
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _relative_days(value):
|
|
||||||
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
|
|
||||||
|
|
||||||
Returns ``None`` when the value is not a relative recipe (an absolute
|
|
||||||
timestamp, a plain number, or garbage).
|
|
||||||
"""
|
|
||||||
s = str(value)
|
|
||||||
if s and s[-1] in "yYdD":
|
|
||||||
try:
|
|
||||||
n = int(s[:-1])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
return n * 365 if s[-1] in "yY" else n
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_locktime_against_tx(current, built, tx_locktime):
|
|
||||||
"""Resolve a locktime recipe against the moment the signed tx was built.
|
|
||||||
|
|
||||||
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
|
|
||||||
target: parsing it against *now* on every check drifts it one day per
|
|
||||||
day away from the fixed locktime frozen inside the signed Bitcoin
|
|
||||||
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
|
|
||||||
plugin asks to invalidate it every day (reported bug). This resolves
|
|
||||||
the current recipe against the build moment instead, recovered from the
|
|
||||||
signed transaction's locktime and the recipe that was actually frozen
|
|
||||||
at build time (``built``, the value stored in the will item):
|
|
||||||
|
|
||||||
build_moment = tx_locktime - duration(built)
|
|
||||||
expected = build_moment + duration(current)
|
|
||||||
|
|
||||||
An unchanged recipe therefore resolves to exactly ``tx_locktime``
|
|
||||||
(coherent), a lengthened one resolves later (postpone) and a shortened
|
|
||||||
one earlier (anticipate).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current: the current locktime recipe (relative or absolute).
|
|
||||||
built: the recipe frozen at build time (stored in the will item).
|
|
||||||
tx_locktime: the absolute locktime frozen inside the signed tx.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: the resolved absolute locktime (UNIX timestamp).
|
|
||||||
"""
|
|
||||||
current_days = Util._relative_days(current)
|
|
||||||
built_days = Util._relative_days(built)
|
|
||||||
if current_days is None:
|
|
||||||
# Absolute current date: compare directly against the frozen tx.
|
|
||||||
try:
|
|
||||||
return int(current)
|
|
||||||
except Exception:
|
|
||||||
return Util.parse_locktime_string(current)
|
|
||||||
if built_days is None or not tx_locktime:
|
|
||||||
# The stored recipe was absolute (a fixed date) or the tx has no
|
|
||||||
# usable locktime: there is no relative anchor to recover the build
|
|
||||||
# moment, so fall back to the legacy forward-from-now resolution.
|
|
||||||
return Util.parse_locktime_string(current)
|
|
||||||
try:
|
|
||||||
base = datetime.fromtimestamp(int(tx_locktime)).replace(
|
|
||||||
hour=0, minute=0, second=0, microsecond=0
|
|
||||||
)
|
|
||||||
build_moment = base - timedelta(days=built_days)
|
|
||||||
return int((build_moment + timedelta(days=current_days)).timestamp())
|
|
||||||
except Exception:
|
|
||||||
return Util.parse_locktime_string(current)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Amount helpers
|
# Amount helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -467,14 +400,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 _, willitem in will.items():
|
for txid, willitem in will.items():
|
||||||
locktimes[willitem["tx"].locktime] = True
|
locktimes[willitem["tx"].locktime] = True
|
||||||
return locktimes.keys()
|
return locktimes.keys()
|
||||||
|
|
||||||
@@ -513,7 +446,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 _, willitem in will.items():
|
for txid, willitem in will.items():
|
||||||
utxos += willitem["tx"].inputs()
|
utxos += willitem["tx"].inputs()
|
||||||
|
|
||||||
return utxos
|
return utxos
|
||||||
@@ -560,106 +493,6 @@ 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."""
|
||||||
|
|||||||
344
bal/core/will.py
344
bal/core/will.py
@@ -40,11 +40,9 @@ 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
|
||||||
|
|
||||||
@@ -221,8 +219,13 @@ 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
|
||||||
elif int(ow.tx_fees) != int(nw.tx_fees):
|
else:
|
||||||
|
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]):
|
||||||
@@ -443,24 +446,18 @@ 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 and inp is not None:
|
if not found:
|
||||||
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, history_label=None,
|
def invalidate_will(will, wallet, fees_per_byte):
|
||||||
will_locktime=None):
|
|
||||||
_logger.debug("invalidate tx in will module")
|
|
||||||
will_only_valid = Will.only_valid_list(will)
|
will_only_valid = Will.only_valid_list(will)
|
||||||
inputs = Will.get_all_inputs(will_only_valid)
|
inputs = Will.get_all_inputs(will_only_valid)
|
||||||
if history_label is not None and will_locktime is not None:
|
|
||||||
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
|
||||||
else:
|
|
||||||
utxos = wallet.get_utxos()
|
utxos = wallet.get_utxos()
|
||||||
filtered_inputs = []
|
filtered_inputs = []
|
||||||
prevout_to_spend = []
|
prevout_to_spend = []
|
||||||
@@ -475,13 +472,11 @@ 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)
|
||||||
@@ -513,7 +508,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
|
||||||
|
|
||||||
@@ -539,26 +534,10 @@ class Will:
|
|||||||
wi.set_status("INVALIDATED", True)
|
wi.set_status("INVALIDATED", True)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# The funding outpoint is not part of the will tree:
|
if wallet.db.get_transaction(wi._id):
|
||||||
# decide from whether a broadcast transaction really
|
|
||||||
# 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)
|
|
||||||
elif spender_height == 0:
|
|
||||||
wi.set_status("MEMPOOL", True)
|
|
||||||
else:
|
|
||||||
wi.set_status("CONFIRMED", True)
|
wi.set_status("CONFIRMED", True)
|
||||||
|
else:
|
||||||
|
wi.set_status("INVALIDATED", 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:
|
||||||
@@ -596,17 +575,9 @@ 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 not wallet or not getattr(wallet, "adb", None):
|
if wallet:
|
||||||
continue
|
|
||||||
height = Will.check_tx_height(w.tx, wallet)
|
height = Will.check_tx_height(w.tx, wallet)
|
||||||
if height < 0:
|
if height < 0:
|
||||||
# The will tx itself is not on-chain. A missing
|
|
||||||
# funding UTXO is only a real problem when a
|
|
||||||
# broadcast transaction actually spends it; a
|
|
||||||
# wallet-local (history) copy of the same will tx
|
|
||||||
# marks the funding spent locally and must not
|
|
||||||
# invalidate the will.
|
|
||||||
if Will._funding_really_spent(wallet, inp_str):
|
|
||||||
Will.set_invalidate(wid, willtree)
|
Will.set_invalidate(wid, willtree)
|
||||||
elif height == 0:
|
elif height == 0:
|
||||||
w.set_status("MEMPOOL", True)
|
w.set_status("MEMPOOL", True)
|
||||||
@@ -627,8 +598,7 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -644,92 +614,13 @@ 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) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
|
if Willexecutors.is_selected(wex):
|
||||||
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.
|
||||||
@@ -745,7 +636,6 @@ 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)
|
||||||
|
|
||||||
@@ -759,186 +649,6 @@ 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)
|
||||||
@@ -1067,7 +777,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 _inputs, wid in all_inputs_min_locktime.items():
|
for prevout_str, wid in all_inputs_min_locktime.items():
|
||||||
for w in wid:
|
for w in wid:
|
||||||
if w[1].get_status("VALID"):
|
if w[1].get_status("VALID"):
|
||||||
locktime = int(wid[0][1].tx.locktime)
|
locktime = int(wid[0][1].tx.locktime)
|
||||||
@@ -1144,6 +854,8 @@ class Will:
|
|||||||
if heir := heirs.get(wheir, None):
|
if heir := heirs.get(wheir, None):
|
||||||
|
|
||||||
if heir[0] == their[0] and heir[1] == their[1]:
|
if heir[0] == their[0] and heir[1] == their[1]:
|
||||||
|
# The requested (possibly new) locktime for this heir.
|
||||||
|
new_locktime = Util.parse_locktime_string(heir[2])
|
||||||
# IMPORTANT: compare against the locktime that is
|
# IMPORTANT: compare against the locktime that is
|
||||||
# actually frozen inside the already-signed Bitcoin
|
# actually frozen inside the already-signed Bitcoin
|
||||||
# transaction (w.tx.locktime), NOT against their[2].
|
# transaction (w.tx.locktime), NOT against their[2].
|
||||||
@@ -1154,16 +866,6 @@ class Will:
|
|||||||
# undetected. w.tx.locktime is immutable once signed
|
# undetected. w.tx.locktime is immutable once signed
|
||||||
# and is exactly what the will-executors hold.
|
# and is exactly what the will-executors hold.
|
||||||
tx_locktime = int(w.tx.locktime)
|
tx_locktime = int(w.tx.locktime)
|
||||||
# The requested (possibly new) locktime for this heir.
|
|
||||||
# A RELATIVE recipe ("1y"/"30d") is resolved against
|
|
||||||
# the moment the signed tx was built, NOT against now:
|
|
||||||
# re-parsing it from "now" drifts it one day per day
|
|
||||||
# away from the frozen tx locktime, so an UNCHANGED
|
|
||||||
# will would be read as a POSTPONE and the plugin
|
|
||||||
# would ask to invalidate it every day.
|
|
||||||
new_locktime = Util.resolve_locktime_against_tx(
|
|
||||||
heir[2], their[2], tx_locktime
|
|
||||||
)
|
|
||||||
if new_locktime == tx_locktime:
|
if new_locktime == tx_locktime:
|
||||||
# Unchanged: this heir is still coherent.
|
# Unchanged: this heir is still coherent.
|
||||||
count = heirs_found.get(wheir, 0)
|
count = heirs_found.get(wheir, 0)
|
||||||
@@ -1235,7 +937,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) and Willexecutors.is_valid(we):
|
if Willexecutors.is_selected(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)
|
||||||
@@ -1272,7 +974,6 @@ 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],
|
||||||
@@ -1331,9 +1032,6 @@ 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):
|
||||||
@@ -1354,8 +1052,6 @@ 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)
|
||||||
@@ -1392,8 +1088,6 @@ 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,16 +17,13 @@ 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, get_version
|
from .plugin_base import BalPlugin
|
||||||
|
|
||||||
# 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
|
||||||
@@ -155,7 +152,7 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutors(
|
def get_willexecutors(
|
||||||
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
|
bal_plugin, update=False, bal_window=False, force=False, task=True
|
||||||
):
|
):
|
||||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||||
willexecutors = willexecutors.get(chainname, {})
|
willexecutors = willexecutors.get(chainname, {})
|
||||||
@@ -217,20 +214,6 @@ 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 = {}
|
||||||
@@ -289,12 +272,10 @@ 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:{get_version()}"
|
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
|
||||||
headers["Content-Type"] = "text/plain"
|
headers["Content-Type"] = "text/plain"
|
||||||
if not handle_response:
|
if not handle_response:
|
||||||
handle_response = Willexecutors.handle_response
|
handle_response = Willexecutors.handle_response
|
||||||
attempts = max_retries + 1
|
|
||||||
for attempt in range(attempts):
|
|
||||||
try:
|
try:
|
||||||
if method == "get":
|
if method == "get":
|
||||||
response = Network.send_http_on_proxy(
|
response = Network.send_http_on_proxy(
|
||||||
@@ -316,21 +297,30 @@ class Willexecutors:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise Exception(f"unexpected {method=!r}")
|
raise Exception(f"unexpected {method=!r}")
|
||||||
_logger.debug(f"--> {response}")
|
|
||||||
return response
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
if attempt < max_retries:
|
if count_reply < max_retries:
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
f"timeout({attempt}) error: "
|
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
||||||
f"retry in {retry_sleep} sec..."
|
|
||||||
)
|
)
|
||||||
if retry_sleep:
|
if retry_sleep:
|
||||||
time.sleep(retry_sleep)
|
time.sleep(retry_sleep)
|
||||||
|
return Willexecutors.send_request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
timeout=timeout,
|
||||||
|
handle_response=handle_response,
|
||||||
|
count_reply=count_reply + 1,
|
||||||
|
max_retries=max_retries,
|
||||||
|
retry_sleep=retry_sleep,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
_logger.debug(f"Too many timeouts: {attempt}")
|
_logger.debug(f"Too many timeouts: {count_reply}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
return None
|
else:
|
||||||
|
_logger.debug(f"--> {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_we_url_from_response(resp):
|
def get_we_url_from_response(resp):
|
||||||
@@ -341,11 +331,15 @@ 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)
|
||||||
except json.JSONDecodeError:
|
# url = Willexecutors.get_we_url_from_response(resp)
|
||||||
|
# r["url"]= url
|
||||||
|
# r["status"]=resp.status
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug(f"error handling response:{e}")
|
||||||
pass
|
pass
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@@ -374,19 +368,17 @@ 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(
|
raise Exception("empty reply from:{willexecutor['url']}")
|
||||||
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() from None
|
raise Willexecutors.AlreadyPresentException()
|
||||||
out = False
|
out = False
|
||||||
willexecutor["broadcast_status"] = _("Failed")
|
willexecutor["broadcast_status"] = _("Failed")
|
||||||
|
|
||||||
@@ -412,34 +404,11 @@ class Willexecutors:
|
|||||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
)
|
)
|
||||||
if isinstance(w, dict):
|
if isinstance(w, dict):
|
||||||
address = w.get("address")
|
|
||||||
if not isinstance(address, str) or not bitcoin.is_address(
|
|
||||||
address, net=constants.net
|
|
||||||
):
|
|
||||||
_logger.warning(
|
|
||||||
f"invalid address from {url}: {address!r}"
|
|
||||||
)
|
|
||||||
willexecutor["status"] = "KO"
|
|
||||||
else:
|
|
||||||
base_fee = w.get("base_fee")
|
|
||||||
try:
|
|
||||||
base_fee = int(base_fee or 0)
|
|
||||||
if base_fee < 0:
|
|
||||||
raise ValueError("negative fee")
|
|
||||||
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
|
|
||||||
raise ValueError("fee exceeds total coin supply")
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
_logger.warning(
|
|
||||||
f"invalid base_fee from {url}: "
|
|
||||||
f"{w.get('base_fee')!r} ({e})"
|
|
||||||
)
|
|
||||||
willexecutor["status"] = "KO"
|
|
||||||
else:
|
|
||||||
willexecutor["url"] = url
|
willexecutor["url"] = url
|
||||||
willexecutor["status"] = 200
|
willexecutor["status"] = 200
|
||||||
willexecutor["base_fee"] = base_fee
|
willexecutor["base_fee"] = w["base_fee"]
|
||||||
willexecutor["address"] = address
|
willexecutor["address"] = w["address"]
|
||||||
willexecutor["info"] = w.get("info", "")
|
willexecutor["info"] = w["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"
|
||||||
@@ -482,7 +451,8 @@ class Willexecutors:
|
|||||||
Returns:
|
Returns:
|
||||||
The same ``willexecutors`` mapping, updated in place.
|
The same ``willexecutors`` mapping, updated in place.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
from concurrent.futures import FIRST_COMPLETED
|
||||||
|
|
||||||
items = list(willexecutors.items())
|
items = list(willexecutors.items())
|
||||||
if not items:
|
if not items:
|
||||||
@@ -562,7 +532,8 @@ 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 FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import 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 = {}
|
||||||
@@ -679,7 +650,8 @@ 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 FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import 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 = {}
|
||||||
@@ -770,14 +742,7 @@ 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)
|
||||||
address = old_willexecutor.get("address", willexecutor.get("address", ""))
|
willexecutor["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"))
|
||||||
|
|
||||||
|
|
||||||
@@ -790,23 +755,14 @@ class Willexecutors:
|
|||||||
"get",
|
"get",
|
||||||
f"{welist_server}data/{chainname}?page=0&limit=100",
|
f"{welist_server}data/{chainname}?page=0&limit=100",
|
||||||
)
|
)
|
||||||
if not isinstance(willexecutors, dict):
|
# del willexecutors["status"]
|
||||||
_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:
|
||||||
@@ -838,12 +794,6 @@ 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}")
|
||||||
|
|||||||
@@ -7,18 +7,12 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
|
|||||||
When a will is built, the plugin can create a calendar event reminding the user
|
When a will is built, the plugin can create a calendar event reminding the user
|
||||||
to "check in" before the locktime expires. This module turns the event data
|
to "check in" before the locktime expires. This module turns the event data
|
||||||
into an RFC-5545 .ics file and opens it with the OS default application.
|
into an RFC-5545 .ics file and opens it with the OS default application.
|
||||||
|
|
||||||
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
|
||||||
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
|
|
||||||
the Qt button and the OS/subprocess glue.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt6.QtGui import QAction
|
|
||||||
from PyQt6.QtWidgets import QToolButton
|
|
||||||
|
|
||||||
from ...core.reminders import write_temp_ics
|
|
||||||
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 PyQt6.QtGui import QAction
|
||||||
|
from PyQt6.QtWidgets import QToolButton
|
||||||
|
|
||||||
|
|
||||||
class BalCalendarButton(QToolButton):
|
class BalCalendarButton(QToolButton):
|
||||||
@@ -62,7 +56,7 @@ class BalCalendarButton(QToolButton):
|
|||||||
try:
|
try:
|
||||||
content = self._ics_provider()
|
content = self._ics_provider()
|
||||||
if content:
|
if content:
|
||||||
self._calendar_temp_path = write_temp_ics(content)
|
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
|
||||||
else:
|
else:
|
||||||
self._calendar_temp_path = None
|
self._calendar_temp_path = None
|
||||||
self._bal_window.show_warning(
|
self._bal_window.show_warning(
|
||||||
@@ -85,8 +79,7 @@ class BalCalendarButton(QToolButton):
|
|||||||
path = self._ensure_ics()
|
path = self._ensure_ics()
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
import shlex
|
import shlex, subprocess
|
||||||
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:
|
||||||
@@ -154,6 +147,13 @@ class BalCalendarButton(QToolButton):
|
|||||||
|
|
||||||
|
|
||||||
class BalCalendar:
|
class BalCalendar:
|
||||||
|
@staticmethod
|
||||||
|
def write_temp_ics(content):
|
||||||
|
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
|
||||||
|
with os.fdopen(fd, "wb") as f:
|
||||||
|
f.write(content.encode("utf-8"))
|
||||||
|
return path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def open_with_default_app(calendar_app, path):
|
def open_with_default_app(calendar_app, path):
|
||||||
_logger.debug("opening calendar app")
|
_logger.debug("opening calendar app")
|
||||||
@@ -182,3 +182,52 @@ class BalCalendar:
|
|||||||
if os.path.isdir(desktop):
|
if os.path.isdir(desktop):
|
||||||
return desktop
|
return desktop
|
||||||
return home
|
return home
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_time(time):
|
||||||
|
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ical_escape(text: str) -> str:
|
||||||
|
# escape per RFC5545: backslash, ; , newlines
|
||||||
|
text = text.encode("utf-8")
|
||||||
|
text = (
|
||||||
|
text.replace(b"\\", b"\\\\")
|
||||||
|
.replace(b";", b"\\;")
|
||||||
|
.replace(b",", b"\\,")
|
||||||
|
)
|
||||||
|
out =""
|
||||||
|
temp=text.split(b"\r\n")
|
||||||
|
for s in temp:
|
||||||
|
encoded= s
|
||||||
|
cut =0
|
||||||
|
while len(encoded) >75:
|
||||||
|
cut+=5
|
||||||
|
encoded=f"{s[:len(s)-cut]}"
|
||||||
|
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
|
||||||
|
cut += 1
|
||||||
|
encoded=f"{s[:len(s)-cut]}"
|
||||||
|
encoded=f"{encoded}...\r\n".encode("utf-8")
|
||||||
|
if cut>0:
|
||||||
|
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
|
||||||
|
else:
|
||||||
|
out+=str(f"{s.decode()}\r\n")
|
||||||
|
|
||||||
|
return out[:-2]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||||
|
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
|
||||||
|
encoded = line.encode("utf-8")
|
||||||
|
parts = []
|
||||||
|
while len(encoded) > limit:
|
||||||
|
# taglia senza spezzare byte UTF-8
|
||||||
|
cut = limit
|
||||||
|
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
|
||||||
|
cut -= 1
|
||||||
|
parts.append(encoded[:cut].decode("utf-8"))
|
||||||
|
encoded = encoded[cut:]
|
||||||
|
parts.append(encoded.decode("utf-8"))
|
||||||
|
return "\r\n ".join(parts)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
|||||||
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
||||||
* :func:`log_error` - format an exception traceback for a dialog.
|
* :func:`log_error` - format an exception traceback for a dialog.
|
||||||
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
||||||
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
@@ -27,138 +27,62 @@ 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, NLOCKTIME_MIN
|
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
|
||||||
|
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 (
|
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
|
||||||
Buttons,
|
EnterButton, HelpButton, MessageBoxMixin,
|
||||||
CancelButton,
|
OkButton, TaskThread, WindowModalDialog,
|
||||||
ColorScheme,
|
char_width_in_lineedit, getSaveFileName,
|
||||||
EnterButton,
|
import_meta_gui, read_QIcon_from_bytes,
|
||||||
HelpButton,
|
read_QPixmap_from_bytes, webopen)
|
||||||
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 (
|
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
|
||||||
DECIMAL_POINT,
|
decimal_point_to_base_unit_name, read_json_file,
|
||||||
FileExportFailed,
|
write_json_file)
|
||||||
FileImportFailed,
|
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
|
||||||
UserCancelled,
|
Qt, QTimer, pyqtSignal)
|
||||||
decimal_point_to_base_unit_name,
|
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
||||||
read_json_file,
|
QStandardItemModel)
|
||||||
write_json_file,
|
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
||||||
)
|
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
|
||||||
from PyQt6.QtCore import (
|
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
|
||||||
QDateTime,
|
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
||||||
QModelIndex,
|
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
|
||||||
QPersistentModelIndex,
|
QVBoxLayout, QWidget, QDialog)
|
||||||
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 (
|
from ...core.will import (AmountException, HeirChangeException,
|
||||||
AmountException,
|
HeirNotFoundException, NoHeirsException,
|
||||||
HeirChangeException,
|
NotCompleteWillException, NoWillExecutorNotPresent,
|
||||||
HeirNotFoundException,
|
TxFeesChangedException, Will,
|
||||||
NoHeirsException,
|
WillexecutorChangeException, WillExecutorNotPresent,
|
||||||
NotCompleteWillException,
|
WillExpiredException, WillItem, WillPostponedException)
|
||||||
NoWillExecutorNotPresent,
|
from ...core.willexecutors import Willexecutors
|
||||||
TxFeesChangedException,
|
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
|
||||||
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 (
|
from .theme import server_status_text, server_status_tooltip, status_color
|
||||||
server_status_text,
|
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
||||||
server_status_tooltip,
|
stop_thread, top_level_of)
|
||||||
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: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
class shown_cv:
|
||||||
_type = bool
|
_type = bool
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
@@ -181,6 +105,18 @@ def add_widget(grid, label, widget, row, help_):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CheckAliveError(Exception):
|
||||||
|
def __init__(self, timestamp_to_check):
|
||||||
|
self.timestamp_to_check = timestamp_to_check
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Check alive expired please update it: {}".format(
|
||||||
|
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def log_error(exec_info, window=None):
|
def log_error(exec_info, window=None):
|
||||||
"""Log an error and optionally show it.
|
"""Log an error and optionally show it.
|
||||||
|
|
||||||
|
|||||||
@@ -17,21 +17,13 @@ the few list classes they reference are imported lazily inside the methods that
|
|||||||
use them (see ``lists`` imports below).
|
use them (see ``lists`` imports below).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ...core.checkalive import CheckAliveError
|
|
||||||
from ...core.reminders import build_ics_reminders
|
|
||||||
from .calendar import BalCalendarButton
|
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .widgets import (
|
from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget,
|
||||||
WillSettingsWidget,
|
LockTimeWidget, PercAmountEdit, ThresholdTimeWidget,
|
||||||
WillWidget,
|
WillSettingsWidget, WillWidget, basic_reminder_offsets,
|
||||||
)
|
compute_reminder_offsets)
|
||||||
|
from .calendar import BalCalendar, BalCalendarButton
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .window import BalWindow
|
|
||||||
|
|
||||||
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
# NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are
|
||||||
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
# imported lazily where needed to avoid a dialogs<->lists import cycle.
|
||||||
|
|
||||||
@@ -39,7 +31,9 @@ if TYPE_CHECKING:
|
|||||||
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)
|
||||||
|
|
||||||
@@ -472,6 +466,7 @@ 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.
|
||||||
@@ -486,6 +481,9 @@ 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
|
||||||
@@ -626,12 +624,7 @@ 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(
|
||||||
@@ -652,17 +645,9 @@ 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,
|
||||||
Util.get_available_utxos(
|
self.bal_window.window.wallet.get_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)
|
||||||
@@ -674,10 +659,6 @@ 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
|
||||||
@@ -713,14 +694,9 @@ 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:
|
except NoHeirsException as e:
|
||||||
_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:
|
||||||
@@ -824,15 +800,6 @@ 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
|
||||||
@@ -1012,14 +979,6 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
desired behaviour, and that the date shown in the panel/wizard must
|
desired behaviour, and that the date shown in the panel/wizard must
|
||||||
reflect this anticipated date (so the calendar .ics also uses it).
|
reflect this anticipated date (so the calendar .ics also uses it).
|
||||||
|
|
||||||
RELATIVE dates are additionally normalised here: a relative value
|
|
||||||
("30d"/"1y") is re-parsed against "now" on every check, so it drifts
|
|
||||||
away from the fixed transaction locktime and the postpone check would
|
|
||||||
wrongly ask to invalidate the will every day. The stored locktime is
|
|
||||||
therefore frozen to the built transactions' absolute locktime, and a
|
|
||||||
relative threshold is frozen to its "N days before the delivery"
|
|
||||||
absolute value.
|
|
||||||
|
|
||||||
We route the update through BalWindow.update_setting_widgets, which is
|
We route the update through BalWindow.update_setting_widgets, which is
|
||||||
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
the single place that (1) stores the value in WILL_SETTINGS, (2)
|
||||||
persists it to Electrum's database and (3) refreshes the date widgets in
|
persists it to Electrum's database and (3) refreshes the date widgets in
|
||||||
@@ -1032,72 +991,30 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
if min_locktime is None:
|
if min_locktime is None:
|
||||||
return
|
return
|
||||||
min_locktime = int(min_locktime)
|
min_locktime = int(min_locktime)
|
||||||
stored_locktime = self.bal_window.will_settings["locktime"]
|
|
||||||
# A relative value ("30d"/"1y") is a MOVING TARGET: it is re-parsed
|
|
||||||
# against "now" on every check, so it drifts one day per day away from
|
|
||||||
# the fixed tx locktime and the postpone check would ALWAYS see a
|
|
||||||
# postpone -> the plugin asks to invalidate the will every day. It must
|
|
||||||
# therefore be normalised here to the frozen absolute locktime of the
|
|
||||||
# built transactions, even when it happens to parse to the same moment
|
|
||||||
# today. (Only an absolute stored value is comparable, see below.)
|
|
||||||
is_relative_locktime = (
|
|
||||||
isinstance(stored_locktime, str)
|
|
||||||
and stored_locktime[-1:].lower() in ("d", "y")
|
|
||||||
)
|
|
||||||
# Current stored delivery date, as a comparable UNIX timestamp.
|
# Current stored delivery date, as a comparable UNIX timestamp.
|
||||||
try:
|
try:
|
||||||
current = int(Util.parse_locktime_string(stored_locktime))
|
current = int(
|
||||||
except Exception:
|
|
||||||
# If the stored value cannot be parsed, fall back to syncing.
|
|
||||||
current = None
|
|
||||||
# A genuine user-chosen POSTPONE (a later absolute date) is never
|
|
||||||
# overwritten; anything else is synced to the built transactions.
|
|
||||||
was_anticipation = current is not None and min_locktime < current
|
|
||||||
if not is_relative_locktime and current is not None and not was_anticipation:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
_logger.debug(
|
|
||||||
f"sync delivery date to built tx locktime: "
|
|
||||||
f"{current} -> {min_locktime}"
|
|
||||||
)
|
|
||||||
# Remember that we anticipated the date, so the later sign prompt can
|
|
||||||
# explain WHY signing is needed (see on_success_phase1). A pure
|
|
||||||
# relative->absolute normalisation is NOT an anticipation.
|
|
||||||
if was_anticipation:
|
|
||||||
self._date_was_anticipated = True
|
|
||||||
# update_setting_widgets stores the value, persists it and refreshes
|
|
||||||
# the date widgets in all panels/wizard (the .ics calendar too).
|
|
||||||
self.bal_window.update_setting_widgets(
|
|
||||||
min_locktime, "locktime", update_all=True
|
|
||||||
)
|
|
||||||
# Same moving-target problem for a relative "Check Alive" threshold:
|
|
||||||
# it means "N days BEFORE the delivery" (the settings widget resolves it
|
|
||||||
# as real_threshold = locktime - N days), so it is normalised to that
|
|
||||||
# absolute date, referenced against the now-absolute stored locktime.
|
|
||||||
threshold_raw = self.bal_window.will_settings.get("threshold")
|
|
||||||
if (
|
|
||||||
isinstance(threshold_raw, str)
|
|
||||||
and threshold_raw[-1:].lower() in ("d", "y")
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
locktime_ts = int(
|
|
||||||
Util.parse_locktime_string(
|
Util.parse_locktime_string(
|
||||||
self.bal_window.will_settings["locktime"]
|
self.bal_window.will_settings["locktime"]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
real_threshold = int(
|
except Exception:
|
||||||
BalTimestamp(threshold_raw)
|
# If the stored value cannot be parsed, fall back to syncing.
|
||||||
.to_date(locktime_ts, reverse=True)
|
current = None
|
||||||
.timestamp()
|
# Only anticipate (move the date EARLIER); never overwrite a postpone.
|
||||||
)
|
if current is not None and min_locktime >= current:
|
||||||
except Exception as e:
|
return
|
||||||
_logger.error(f"sync threshold to absolute failed: {e}")
|
|
||||||
else:
|
|
||||||
_logger.debug(
|
_logger.debug(
|
||||||
f"sync threshold {threshold_raw} -> absolute {real_threshold}"
|
f"sync delivery date to anticipated tx locktime: "
|
||||||
|
f"{current} -> {min_locktime}"
|
||||||
)
|
)
|
||||||
|
# Remember that we anticipated the date, so the later sign prompt can
|
||||||
|
# explain WHY signing is needed (see on_success_phase1).
|
||||||
|
self._date_was_anticipated = True
|
||||||
|
# update_setting_widgets stores the value, persists it and refreshes the
|
||||||
|
# date widgets in all panels/wizard (so the .ics calendar uses it too).
|
||||||
self.bal_window.update_setting_widgets(
|
self.bal_window.update_setting_widgets(
|
||||||
real_threshold, "threshold", update_all=True
|
min_locktime, "locktime", update_all=True
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_accept(self):
|
def on_accept(self):
|
||||||
@@ -1196,13 +1113,7 @@ 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(
|
if Willexecutors.is_selected(self.bal_window.willexecutors.get(url))
|
||||||
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
|
||||||
@@ -1435,10 +1346,6 @@ 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:
|
||||||
@@ -1544,13 +1451,6 @@ 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
|
||||||
@@ -1579,76 +1479,9 @@ 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
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
try:
|
try:
|
||||||
locktime_ts = Util.parse_locktime_string(
|
locktime_ts = Util.parse_locktime_string(
|
||||||
@@ -1656,25 +1489,22 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
)
|
)
|
||||||
locktime = datetime.fromtimestamp(locktime_ts)
|
locktime = datetime.fromtimestamp(locktime_ts)
|
||||||
|
|
||||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
if basic_mode:
|
days_to_deadline = (locktime - datetime.now()).days
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
offsets = basic_reminder_offsets(days_to_deadline)
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
|
||||||
threshold = None
|
|
||||||
num_reminders = 3
|
|
||||||
else:
|
else:
|
||||||
threshold_ts = BalTimestamp(
|
threshold_ts = BalTimestamp(
|
||||||
self.bal_window.will_settings["threshold"]
|
self.bal_window.will_settings["threshold"]
|
||||||
).to_timestamp()
|
).to_timestamp()
|
||||||
threshold = datetime.fromtimestamp(threshold_ts)
|
threshold = datetime.fromtimestamp(threshold_ts)
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
days = (locktime - threshold).days
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
|
||||||
try:
|
try:
|
||||||
num_reminders = int(
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
num_reminders = 3
|
count = 3
|
||||||
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
|
now = BalCalendar.format_time(datetime.now())
|
||||||
|
|
||||||
heirs_details = "\r\n".join(
|
heirs_details = "\r\n".join(
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||||
@@ -1682,21 +1512,56 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
for heir in self.bal_window.heirs
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
# ToDo #2: when no reminder falls in the future (the delivery date is
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
# too close or already passed), build_ics_reminders returns None so
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||||
# the caller shows a clear warning instead of producing an empty,
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||||
# seemingly-broken .ics file.
|
else:
|
||||||
return build_ics_reminders(
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||||
locktime=locktime,
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||||
basic_mode=basic_mode,
|
|
||||||
description=raw_description,
|
event_description = BalCalendar.ical_escape(
|
||||||
summary=raw_summary,
|
f"{raw_description}"
|
||||||
wallet_name=str(self.bal_window.wallet),
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
heirs_details=heirs_details,
|
.replace("$heirs_complete", heirs_details)
|
||||||
version=self.bal_window.bal_plugin.version,
|
|
||||||
num_reminders=num_reminders,
|
|
||||||
threshold=threshold,
|
|
||||||
)
|
)
|
||||||
|
summary_base = (
|
||||||
|
f"{raw_summary}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||||||
|
f"{BalPlugin.__version__}",
|
||||||
|
]
|
||||||
|
|
||||||
|
total = len(offsets)
|
||||||
|
# ToDo #2: if no reminder falls in the future (the delivery date is
|
||||||
|
# too close or already passed), there are no events to write.
|
||||||
|
# Return None so the caller shows a clear warning instead of
|
||||||
|
# producing an empty, seemingly-broken .ics file.
|
||||||
|
if total == 0:
|
||||||
|
return None
|
||||||
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
|
summary = BalCalendar.ical_escape(
|
||||||
|
f"{summary_base} (reminder {idx}/{total})"
|
||||||
|
)
|
||||||
|
lines.extend([
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||||||
|
f"DTSTAMP:{now}",
|
||||||
|
f"DTSTART:{event_dt}",
|
||||||
|
f"DTEND:{event_dt}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
f"DESCRIPTION:{event_description}",
|
||||||
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"failed to generate .ics: {e}")
|
_logger.error(f"failed to generate .ics: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1724,12 +1589,7 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
try:
|
try:
|
||||||
if txs := self.bal_window.sign_transactions(password):
|
if txs := self.bal_window.sign_transactions(password):
|
||||||
for txid, tx in txs.items():
|
for txid, tx in txs.items():
|
||||||
# Re-parse instead of deepcopy (the signed tx can carry
|
self.bal_window.willitems[txid].tx = copy.deepcopy(tx)
|
||||||
# wallet-derived input info holding a threading.RLock,
|
|
||||||
# which copy.deepcopy cannot pickle).
|
|
||||||
self.bal_window.willitems[txid].tx = Will.get_tx_from_any(
|
|
||||||
str(tx)
|
|
||||||
)
|
|
||||||
self.bal_window.save_willitems()
|
self.bal_window.save_willitems()
|
||||||
self.msg_set_signing(self.msg_ok())
|
self.msg_set_signing(self.msg_ok())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2016,17 +1876,9 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
|
|
||||||
|
|
||||||
class WillDetailDialog(BalDialog):
|
class WillDetailDialog(BalDialog):
|
||||||
def __init__(self, bal_window, will=None, threshold=None):
|
def __init__(self, bal_window):
|
||||||
# ``will``/``threshold`` are passed when showing an IMPORTED (read-only)
|
|
||||||
# will. In that case every action button below operates on the imported
|
self.will = bal_window.willitems
|
||||||
# will, never on the live wallet state.
|
|
||||||
self._external_will = will is not None
|
|
||||||
self.will = will if self._external_will else bal_window.willitems
|
|
||||||
if threshold is not None:
|
|
||||||
self.threshold = threshold
|
|
||||||
elif self._external_will:
|
|
||||||
self.threshold = max(wi.tx.locktime for wi in self.will.values())
|
|
||||||
else:
|
|
||||||
self.threshold = bal_window.will_settings["real_threshold"]
|
self.threshold = bal_window.will_settings["real_threshold"]
|
||||||
|
|
||||||
self.bal_window = bal_window
|
self.bal_window = bal_window
|
||||||
@@ -2059,14 +1911,8 @@ 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(self.invalidate_will)
|
b.clicked.connect(bal_window.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()
|
||||||
@@ -2087,48 +1933,22 @@ 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, will=self.will)
|
self.detailsWidget = WillWidget(parent=self)
|
||||||
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(
|
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
|
||||||
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(will=self.will if self._external_will else None)
|
self.bal_window.export_will()
|
||||||
|
|
||||||
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()
|
||||||
@@ -2147,7 +1967,6 @@ class WillDetailDialog(BalDialog):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
if not self._external_will:
|
|
||||||
self.will = self.bal_window.willitems
|
self.will = self.bal_window.willitems
|
||||||
pos = self.vlayout.indexOf(self.scrollbox)
|
pos = self.vlayout.indexOf(self.scrollbox)
|
||||||
self.vlayout.removeWidget(self.scrollbox)
|
self.vlayout.removeWidget(self.scrollbox)
|
||||||
|
|||||||
@@ -14,60 +14,12 @@ 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):
|
||||||
@@ -132,7 +84,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):
|
||||||
prior_name = self.bal_window.heirs.get(edit_key)
|
original = 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()
|
||||||
@@ -152,6 +104,11 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
self.bal_window.set_heir(prior_name)
|
self.bal_window.set_heir(prior_name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.bal_window.set_heir((edit_key,) + original)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -200,16 +157,6 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
heir = self.bal_window.heirs[key]
|
heir = self.bal_window.heirs[key]
|
||||||
labels = [""] * len(self.Columns)
|
labels = [""] * len(self.Columns)
|
||||||
labels[self.Columns.NAME] = key
|
labels[self.Columns.NAME] = key
|
||||||
if is_op_return_address(heir[0]):
|
|
||||||
data_hex = heir[0][len(OP_RETURN_PREFIX):]
|
|
||||||
try:
|
|
||||||
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
|
|
||||||
if len(decoded) > 40:
|
|
||||||
decoded = decoded[:40] + "\u2026"
|
|
||||||
labels[self.Columns.ADDRESS] = decoded
|
|
||||||
except Exception:
|
|
||||||
labels[self.Columns.ADDRESS] = "OP_RETURN"
|
|
||||||
else:
|
|
||||||
labels[self.Columns.ADDRESS] = heir[0]
|
labels[self.Columns.ADDRESS] = heir[0]
|
||||||
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
||||||
heir[1], self.decimal_point
|
heir[1], self.decimal_point
|
||||||
@@ -237,7 +184,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:
|
except Exception as e:
|
||||||
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:
|
||||||
@@ -257,15 +204,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())
|
||||||
|
|
||||||
new_heir_button = QPushButton(_("New Heir"))
|
newHeirButton = QPushButton(_("New Heir"))
|
||||||
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
|
newHeirButton.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(new_heir_button)
|
layout.addWidget(newHeirButton)
|
||||||
|
|
||||||
toolbar.insertWidget(2, widget)
|
toolbar.insertWidget(2, widget)
|
||||||
|
|
||||||
@@ -324,7 +271,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:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
@@ -350,64 +297,36 @@ 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) == 1)
|
).setEnabled(len(selected_keys) < 2)
|
||||||
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():
|
||||||
menu.addSeparator()
|
try:
|
||||||
menu.addAction(
|
self.importaction = self.menu.addAction(
|
||||||
_("copy id").format(column_title),
|
_("Import"), self.import_will
|
||||||
lambda: self.copy_txids(selected_keys),
|
|
||||||
)
|
|
||||||
menu.addAction(
|
|
||||||
_("copy").format(column_title),
|
|
||||||
lambda: self.copy_tx_hexes(selected_keys),
|
|
||||||
)
|
|
||||||
menu.addAction(
|
|
||||||
_("merge from txn").format(column_title),
|
|
||||||
lambda: self.merge_from_txn(),
|
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
||||||
).setEnabled(
|
|
||||||
any(_can_delete(self.will.get(k)) for k in selected_keys)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
menu.exec(self.viewport().mapToGlobal(position))
|
menu.exec(self.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
def is_deletable(self, key):
|
|
||||||
"""True if the will transaction ``key`` may be deleted.
|
|
||||||
|
|
||||||
Deletion is only allowed for transactions that are NOT valid or NOT
|
|
||||||
fully signed (invalidated/replaced/mempool/confirmed items, or
|
|
||||||
unsigned/partially signed ones). Valid and complete transactions are
|
|
||||||
kept (deleting them would silently drop a committed inheritance).
|
|
||||||
"""
|
|
||||||
return _can_delete(self.will.get(key))
|
|
||||||
|
|
||||||
def delete(self, selected_keys):
|
def delete(self, selected_keys):
|
||||||
keys = [k for k in selected_keys if self.is_deletable(k)]
|
for key in selected_keys:
|
||||||
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]
|
||||||
@@ -419,75 +338,6 @@ 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:
|
||||||
@@ -535,8 +385,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 + signature_suffix(bal_tx)
|
status = bal_tx.status
|
||||||
if len(status) > 53:
|
if len(bal_tx.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
|
||||||
@@ -612,12 +462,9 @@ 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)
|
||||||
export_menu = menu.addMenu(_("Export"))
|
menu.addAction(_("Export"), self.export_will)
|
||||||
export_menu.addAction(_("All"), self.export_will)
|
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
||||||
export_menu.addAction(_("Valid"), self.export_will_valid)
|
self.importaction = menu.addAction(_("Import"), self.import_will)
|
||||||
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)
|
||||||
@@ -689,37 +536,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
self.bal_window.export_will()
|
self.bal_window.export_will()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def export_will_valid(self):
|
def import_will(self):
|
||||||
"""Export only the will items that are valid."""
|
self.bal_window.import_will()
|
||||||
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)
|
||||||
@@ -938,7 +756,6 @@ 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
|
||||||
@@ -954,15 +771,6 @@ 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),
|
||||||
@@ -1121,17 +929,6 @@ 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)
|
||||||
@@ -1211,44 +1008,13 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
b.clicked.connect(self.import_file)
|
b.clicked.connect(self.import_file)
|
||||||
buttonbox.addWidget(b)
|
buttonbox.addWidget(b)
|
||||||
|
|
||||||
def _menu_button(label):
|
b = QPushButton(_("Export"))
|
||||||
btn = QToolButton()
|
b.clicked.connect(self.export_file)
|
||||||
btn.setText(_(label))
|
buttonbox.addWidget(b)
|
||||||
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
|
||||||
buttonbox.addWidget(btn)
|
|
||||||
return btn
|
|
||||||
|
|
||||||
export_btn = _menu_button("Export")
|
b = QPushButton(_("Ping All"))
|
||||||
export_menu = QMenu(export_btn)
|
b.clicked.connect(self.update_willexecutors)
|
||||||
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
buttonbox.addWidget(b)
|
||||||
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()
|
||||||
@@ -1369,7 +1135,6 @@ 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)
|
||||||
@@ -1462,68 +1227,13 @@ 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, subset=None):
|
def export_file(self, path):
|
||||||
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,
|
self.bal_window.window, "willexecutors.json", self.export_json_file
|
||||||
"willexecutors.json",
|
|
||||||
partial(self.export_json_file, subset=subset),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_data(self, subset=None):
|
def export_json_file(self, path):
|
||||||
data = self.willexecutors_list
|
write_json_file(path, 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,17 +15,14 @@ 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 ( # underscore names are not re-exported by "import *"
|
from .common import _, _logger # 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):
|
||||||
@@ -91,11 +88,9 @@ class Plugin(BalPlugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from electrum.gui.qt.plugins_dialog import (
|
from electrum.gui.qt.plugins_dialog import PluginsDialog
|
||||||
PluginsDialog as plugins_dialog, # noqa: N813
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
plugins_dialog = None
|
PluginsDialog = None
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if app is None:
|
||||||
return []
|
return []
|
||||||
@@ -111,7 +106,7 @@ class Plugin(BalPlugin):
|
|||||||
for w in app.topLevelWidgets():
|
for w in app.topLevelWidgets():
|
||||||
try:
|
try:
|
||||||
is_match = False
|
is_match = False
|
||||||
if plugins_dialog is not None and isinstance(w, plugins_dialog):
|
if PluginsDialog is not None and isinstance(w, PluginsDialog):
|
||||||
is_match = True
|
is_match = True
|
||||||
elif type(w).__name__ == "PluginsDialog":
|
elif type(w).__name__ == "PluginsDialog":
|
||||||
is_match = True
|
is_match = True
|
||||||
@@ -147,17 +142,17 @@ class Plugin(BalPlugin):
|
|||||||
each is guarded independently.
|
each is guarded independently.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
|
from PyQt6.QtWidgets import QDialog
|
||||||
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.
|
||||||
@@ -177,9 +172,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 as qtimer # noqa: N813
|
from PyQt6.QtCore import QTimer
|
||||||
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()
|
||||||
@@ -195,8 +190,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),
|
||||||
)
|
)
|
||||||
@@ -441,13 +436,6 @@ 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
|
||||||
@@ -510,10 +498,8 @@ 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_11, reset_btn_12, reset_btn_auto_sign):
|
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
|
||||||
@@ -539,20 +525,6 @@ 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")
|
||||||
@@ -664,28 +636,13 @@ 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,
|
||||||
6,
|
5,
|
||||||
(
|
(
|
||||||
"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 "
|
||||||
@@ -696,7 +653,7 @@ class Plugin(BalPlugin):
|
|||||||
"editable."
|
"editable."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 6, 3)
|
grid.addWidget(_make_reset_btn(self.USER_TYPE, user_type_combo, "user_type"), 5, 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.
|
||||||
@@ -705,11 +662,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), 7, 0)
|
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 7, 1)
|
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_num_reminders), 7, 2)
|
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 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), 7, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 3)
|
||||||
|
|
||||||
lbl_event_summary = QLabel(_("Event summary"))
|
lbl_event_summary = QLabel(_("Event summary"))
|
||||||
help_event_summary = HelpButton(
|
help_event_summary = HelpButton(
|
||||||
@@ -719,11 +676,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), 8, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_summary), 8, 1)
|
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_summary), 8, 2)
|
grid.addWidget(_hide_if_basic(help_event_summary), 7, 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), 8, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 3)
|
||||||
|
|
||||||
lbl_event_description = QLabel(_("Event description"))
|
lbl_event_description = QLabel(_("Event description"))
|
||||||
help_event_description = HelpButton(
|
help_event_description = HelpButton(
|
||||||
@@ -733,11 +690,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), 9, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_description), 9, 1)
|
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_description), 9, 2)
|
grid.addWidget(_hide_if_basic(help_event_description), 8, 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), 9, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_8), 8, 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"))
|
||||||
@@ -745,11 +702,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), 10, 0)
|
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_welist_server), 10, 1)
|
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_welist_server), 10, 2)
|
grid.addWidget(_hide_if_basic(help_welist_server), 9, 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), 10, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 3)
|
||||||
|
|
||||||
lbl_calendar_app = QLabel(_("Calendar app command"))
|
lbl_calendar_app = QLabel(_("Calendar app command"))
|
||||||
help_calendar_app = HelpButton(
|
help_calendar_app = HelpButton(
|
||||||
@@ -757,42 +714,11 @@ 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), 11, 0)
|
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 11, 1)
|
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_calendar_app), 11, 2)
|
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 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), 11, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_10), 10, 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
|
||||||
@@ -801,12 +727,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, 14, 0)
|
grid.addWidget(heir_repush, 11, 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"
|
||||||
),
|
),
|
||||||
14,
|
11,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -835,13 +761,10 @@ 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.
|
||||||
@@ -862,10 +785,6 @@ 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,31 +54,12 @@ 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,21 +18,89 @@ Contents:
|
|||||||
* WillWidget - single will-tx box
|
* WillWidget - single will-tx box
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ...core.input_rules import (
|
|
||||||
LockTimeEditor,
|
|
||||||
normalize_locktime_raw_text,
|
|
||||||
normalize_perc_amount_text,
|
|
||||||
parse_perc_amount,
|
|
||||||
)
|
|
||||||
from ...core.reminders import build_ics_reminders, write_temp_ics
|
|
||||||
from .calendar import BalCalendar, BalCalendarButton
|
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
|
from .calendar import BalCalendar, BalCalendarButton
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .window import BalWindow
|
def compute_reminder_offsets(days, count):
|
||||||
|
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
||||||
|
|
||||||
|
Group D / D1. The reminders are spread uniformly across the check-alive
|
||||||
|
period and always fall *before* the delivery deadline, i.e. every returned
|
||||||
|
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
* ``count`` is the requested number of reminders (the settings dialog
|
||||||
|
caps it at 5, default 3).
|
||||||
|
* at most ONE reminder per available day: the effective number is
|
||||||
|
``min(count, days)``;
|
||||||
|
* with ``days`` available days, offsets are chosen as evenly spaced
|
||||||
|
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
||||||
|
= the first day of the period), de-duplicated and returned sorted
|
||||||
|
descending (earliest reminder first).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: number of whole days between check-alive and the deadline.
|
||||||
|
count: requested number of reminders.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||||||
|
``days=30, count=3``. Empty if there is no room for any reminder.
|
||||||
|
"""
|
||||||
|
# No room for any reminder (deadline today or already passed).
|
||||||
|
if days < 1 or count < 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Never more reminders than available days (one per day at most).
|
||||||
|
effective = min(int(count), int(days))
|
||||||
|
|
||||||
|
# A single reminder: put it one day before the deadline.
|
||||||
|
if effective == 1:
|
||||||
|
return [1]
|
||||||
|
|
||||||
|
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
||||||
|
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
||||||
|
# This places the first reminder at the start of the period (offset ~days)
|
||||||
|
# and the last one one day before the deadline (offset 1).
|
||||||
|
offsets = set()
|
||||||
|
for i in range(effective):
|
||||||
|
frac = i / (effective - 1) # 0.0 .. 1.0
|
||||||
|
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
||||||
|
offset = round(days - frac * (days - 1))
|
||||||
|
offset = max(1, min(days, offset))
|
||||||
|
offsets.add(offset)
|
||||||
|
|
||||||
|
# Sorted descending: earliest reminder (largest offset) first.
|
||||||
|
return sorted(offsets, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
|
||||||
|
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
|
||||||
|
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
|
||||||
|
# 1 day before the inheritance delivery date.
|
||||||
|
BASIC_REMINDER_OFFSETS = (30, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def basic_reminder_offsets(days_to_deadline):
|
||||||
|
"""Return the BASIC-mode reminder offsets that still fall in the future.
|
||||||
|
|
||||||
|
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
|
||||||
|
1 day before the delivery date). Any offset that would land in the past is
|
||||||
|
dropped, because a reminder before "today" is useless: if the delivery date
|
||||||
|
is only ``days_to_deadline`` days away, only the offsets that are ``<=
|
||||||
|
days_to_deadline`` are kept.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_to_deadline: whole days from now until the delivery date.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of integer day-offsets (each ``>= 1``), sorted as in
|
||||||
|
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
|
||||||
|
when the delivery date is less than one day away.
|
||||||
|
"""
|
||||||
|
horizon = max(int(days_to_deadline), 0)
|
||||||
|
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
|
||||||
|
|
||||||
|
|
||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
@@ -146,12 +214,39 @@ class BalTxFeesWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _LockTimeEditor(LockTimeEditor):
|
class _LockTimeEditor:
|
||||||
"""Qt-side locktime editor base.
|
min_allowed_value = NLOCKTIME_MIN
|
||||||
|
max_allowed_value = NLOCKTIME_MAX
|
||||||
|
alarm = None
|
||||||
|
|
||||||
|
def get_value(self) -> Optional[int]:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def set_value(self, x: Any, force=True) -> None:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_acceptable_locktime(cls, x: Any) -> bool:
|
||||||
|
if not x: # e.g. empty string
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
x = int(x)
|
||||||
|
except Exception as _e:
|
||||||
|
return False
|
||||||
|
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_max_allowed_timestamp() -> int:
|
||||||
|
ts = NLOCKTIME_MAX
|
||||||
|
# Test if this value is within the valid timestamp limits (which is platform-dependent).
|
||||||
|
# see #6170
|
||||||
|
try:
|
||||||
|
datetime.fromtimestamp(ts)
|
||||||
|
except (OSError, OverflowError):
|
||||||
|
ts = 2**31 - 1 # INT32_MAX
|
||||||
|
datetime.fromtimestamp(ts) # test if raises
|
||||||
|
return ts
|
||||||
|
|
||||||
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
|
|
||||||
this is just the mixin that lets Qt widgets reuse them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||||
@@ -472,16 +567,51 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
|||||||
self.isyears = False
|
self.isyears = False
|
||||||
self.time_edit = time_edit
|
self.time_edit = time_edit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def replace_str(text):
|
||||||
|
"""Strip the relative-time suffixes (d/y) from the text.
|
||||||
|
|
||||||
|
Only days ("d") and years ("y") are supported. The block-height
|
||||||
|
suffix ("b") was removed (A1): locktimes are always timestamps now.
|
||||||
|
"""
|
||||||
|
return str(text).replace("d", "").replace("y", "")
|
||||||
|
|
||||||
|
def checkbdy(self, s, pos, appendix):
|
||||||
|
try:
|
||||||
|
charpos = pos - 1
|
||||||
|
charpos = max(0, charpos)
|
||||||
|
charpos = min(len(s) - 1, charpos)
|
||||||
|
if appendix == s[charpos]:
|
||||||
|
s = self.replace_str(s) + appendix
|
||||||
|
pos = charpos
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return pos, s
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
||||||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
# The block-height suffix ("b") was removed (A1): locktimes are always
|
||||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
# UNIX timestamps now, so block-relative input is no longer allowed.
|
||||||
# The sanitisation itself lives in bal.core.input_rules.
|
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
|
chars = "0123456789dy"
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
|
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||||
self.isdays = isdays
|
s = "".join([i for i in text if i in chars])
|
||||||
self.isyears = isyears
|
self.isdays = False
|
||||||
|
self.isyears = False
|
||||||
|
|
||||||
|
pos, s = self.checkbdy(s, pos, "d")
|
||||||
|
pos, s = self.checkbdy(s, pos, "y")
|
||||||
|
|
||||||
|
if "d" in s:
|
||||||
|
self.isdays = True
|
||||||
|
if "y" in s:
|
||||||
|
self.isyears = True
|
||||||
|
|
||||||
|
if self.isdays:
|
||||||
|
s = self.replace_str(s) + "d"
|
||||||
|
if self.isyears:
|
||||||
|
s = self.replace_str(s) + "y"
|
||||||
self.blockSignals(True)
|
self.blockSignals(True)
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.blockSignals(False)
|
self.blockSignals(False)
|
||||||
@@ -560,7 +690,7 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
x = int(x)
|
x = int(x)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
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
|
||||||
@@ -938,12 +1068,11 @@ class WillSettingsWidget(QWidget):
|
|||||||
sees several distinct appointments in their calendar.
|
sees several distinct appointments in their calendar.
|
||||||
|
|
||||||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||||||
capped at 5 by the settings dialog). Their dates are computed by
|
capped at 5 by the settings dialog). Their dates are computed with
|
||||||
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
|
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
||||||
across the check-alive period; the LAST event always falls one day
|
check-alive period and the LAST event always falls one day before the
|
||||||
before the delivery deadline / locktime). If the period is shorter than
|
delivery deadline (locktime). If the period is shorter than the
|
||||||
the requested number of reminders, at most one event per day is
|
requested number of reminders, at most one event per day is produced.
|
||||||
produced.
|
|
||||||
|
|
||||||
Each event:
|
Each event:
|
||||||
* is placed on ``locktime - offset`` days (its own visible date);
|
* is placed on ``locktime - offset`` days (its own visible date);
|
||||||
@@ -958,23 +1087,103 @@ class WillSettingsWidget(QWidget):
|
|||||||
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||||||
on the Desktop).
|
on the Desktop).
|
||||||
"""
|
"""
|
||||||
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
|
now = BalCalendar.format_time(datetime.now())
|
||||||
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
|
|
||||||
# reminder falls in the future it returns None (ToDo #2) and the user is
|
|
||||||
# warned instead of getting an empty-looking file.
|
|
||||||
ics_content = self._ics_provider()
|
|
||||||
if not ics_content:
|
|
||||||
self.bal_window.show_warning(
|
|
||||||
_(
|
|
||||||
"No reminders were saved: the delivery date is too "
|
|
||||||
"close (or already passed)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
# locktime = delivery deadline. It is exposed by the date widget as
|
||||||
|
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
|
||||||
|
# transaction locktime, so the calendar uses the correct delivery date.
|
||||||
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
|
||||||
|
# BASIC vs ADVANCED reminder strategy.
|
||||||
|
#
|
||||||
|
# In ADVANCED mode the reminders are spread uniformly across the
|
||||||
|
# check-alive (threshold) period, ending one day before the deadline.
|
||||||
|
#
|
||||||
|
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
|
||||||
|
# user (it stays at an arbitrary default), so spreading reminders over it
|
||||||
|
# is meaningless. The owner asked that, in BASIC, the calendar simply
|
||||||
|
# saves the inheritance delivery date with three fixed reminders: 30 days
|
||||||
|
# before, 10 days before and 1 day before. We also drop any fixed offset
|
||||||
|
# that would fall in the past (a reminder before "today" is useless), so
|
||||||
|
# a short-dated will still gets the reminders that are still in the
|
||||||
|
# future.
|
||||||
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
|
# Whole days from now until the delivery date. Fixed offsets (30, 10,
|
||||||
|
# 1 day before) are applied by basic_reminder_offsets, which also
|
||||||
|
# drops any offset that would fall in the past.
|
||||||
|
days_to_deadline = (locktime - datetime.now()).days
|
||||||
|
offsets = basic_reminder_offsets(days_to_deadline)
|
||||||
|
else:
|
||||||
|
# ADVANCED: spread reminders over the check-alive period as before.
|
||||||
|
threshold = self.widgets["threshold"].alarm
|
||||||
|
# Whole days available between check-alive and the deadline.
|
||||||
|
days = (locktime - threshold).days
|
||||||
|
# How many reminders the user asked for (default 3 if unreadable).
|
||||||
|
try:
|
||||||
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
|
except Exception:
|
||||||
|
count = 3
|
||||||
|
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
|
||||||
|
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
|
||||||
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
|
# Per-event heir details and the shared description/summary templates.
|
||||||
|
heirs_details = "\r\n".join(
|
||||||
|
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
|
||||||
|
for heir in self.bal_window.heirs
|
||||||
|
)
|
||||||
|
# BASIC mode: use factory defaults (the hidden settings are ignored).
|
||||||
|
# ADVANCED mode: use the user-configured values.
|
||||||
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||||
|
else:
|
||||||
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||||
|
event_description = BalCalendar.ical_escape(
|
||||||
|
f"{raw_description}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
.replace("$heirs_complete", heirs_details)
|
||||||
|
)
|
||||||
|
summary_base = (
|
||||||
|
f"{raw_summary}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# One separate VEVENT per reminder offset (its own date in the calendar).
|
||||||
|
total = len(offsets)
|
||||||
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
|
# The visible date of this event: "offset" days before the deadline.
|
||||||
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
|
# Suffix the summary so the N events are easy to tell apart.
|
||||||
|
summary = BalCalendar.ical_escape(
|
||||||
|
f"{summary_base} (reminder {idx}/{total})"
|
||||||
|
)
|
||||||
|
lines.extend([
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
# Offset in the UID keeps each event unique (no merging).
|
||||||
|
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||||||
|
f"DTSTAMP:{now}",
|
||||||
|
f"DTSTART:{event_dt}",
|
||||||
|
f"DTEND:{event_dt}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
f"DESCRIPTION:{event_description}",
|
||||||
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
|
||||||
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
|
ics_content = "\r\n".join(lines) + "\r\n"
|
||||||
# Keep the generated .ics in a temp file; it is copied to the path the
|
# Keep the generated .ics in a temp file; it is copied to the path the
|
||||||
# user picks below.
|
# user picks below.
|
||||||
self.temp_path = write_temp_ics(ics_content)
|
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||||||
|
|
||||||
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||||||
# plugin no longer tries to open it with a calendar app). The save
|
# plugin no longer tries to open it with a calendar app). The save
|
||||||
@@ -1027,31 +1236,26 @@ class WillSettingsWidget(QWidget):
|
|||||||
def _ics_provider(self):
|
def _ics_provider(self):
|
||||||
"""Return the .ics content for the current locktime/threshold values.
|
"""Return the .ics content for the current locktime/threshold values.
|
||||||
|
|
||||||
Used by :class:`BalCalendarButton` as its content provider. The whole
|
Used by :class:`BalCalendarButton` as its content provider.
|
||||||
document (reminder offsets, escaping, folding, VEVENT layout) is built
|
|
||||||
by the pure :func:`bal.core.reminders.build_ics_reminders`.
|
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
try:
|
try:
|
||||||
locktime = self.widgets["locktime"].alarm
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
|
||||||
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
if basic_mode:
|
days_to_deadline = (locktime - datetime.now()).days
|
||||||
# BASIC mode: use factory defaults (the hidden settings are
|
offsets = basic_reminder_offsets(days_to_deadline)
|
||||||
# ignored) and the fixed 30/10/1 offsets.
|
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
|
||||||
threshold = None
|
|
||||||
num_reminders = 3
|
|
||||||
else:
|
else:
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
|
||||||
threshold = self.widgets["threshold"].alarm
|
threshold = self.widgets["threshold"].alarm
|
||||||
|
days = (locktime - threshold).days
|
||||||
try:
|
try:
|
||||||
num_reminders = int(
|
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||||||
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
num_reminders = 3
|
count = 3
|
||||||
|
offsets = compute_reminder_offsets(days, count)
|
||||||
|
|
||||||
|
now = BalCalendar.format_time(datetime.now())
|
||||||
|
|
||||||
heirs_details = "\r\n".join(
|
heirs_details = "\r\n".join(
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||||
@@ -1059,17 +1263,50 @@ class WillSettingsWidget(QWidget):
|
|||||||
for heir in self.bal_window.heirs
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
return build_ics_reminders(
|
if self.bal_window.bal_plugin.is_basic_mode():
|
||||||
locktime=locktime,
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||||||
basic_mode=basic_mode,
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||||||
description=raw_description,
|
else:
|
||||||
summary=raw_summary,
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||||
wallet_name=str(self.bal_window.wallet),
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||||
heirs_details=heirs_details,
|
|
||||||
version=self.bal_window.bal_plugin.version,
|
event_description = BalCalendar.ical_escape(
|
||||||
num_reminders=num_reminders,
|
f"{raw_description}"
|
||||||
threshold=threshold,
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
.replace("$heirs_complete", heirs_details)
|
||||||
)
|
)
|
||||||
|
summary_base = (
|
||||||
|
f"{raw_summary}"
|
||||||
|
.replace("$wallet_name", str(self.bal_window.wallet))
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||||||
|
f"{BalPlugin.__version__}",
|
||||||
|
]
|
||||||
|
|
||||||
|
total = len(offsets)
|
||||||
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
|
summary = BalCalendar.ical_escape(
|
||||||
|
f"{summary_base} (reminder {idx}/{total})"
|
||||||
|
)
|
||||||
|
lines.extend([
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||||||
|
f"DTSTAMP:{now}",
|
||||||
|
f"DTSTART:{event_dt}",
|
||||||
|
f"DTEND:{event_dt}",
|
||||||
|
f"SUMMARY:{summary}",
|
||||||
|
f"DESCRIPTION:{event_description}",
|
||||||
|
"END:VEVENT",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.append("END:VCALENDAR")
|
||||||
|
lines = [s.rstrip("\r\n") for s in lines]
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"failed to generate .ics: {e}")
|
_logger.error(f"failed to generate .ics: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1112,20 +1349,40 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
# The text sanitisation lives in bal.core.input_rules.
|
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
if text == "!":
|
if text == "!":
|
||||||
self.shortcut.emit()
|
self.shortcut.emit()
|
||||||
return
|
return
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
|
chars = "0123456789%"
|
||||||
|
chars += DECIMAL_POINT
|
||||||
|
|
||||||
|
s = "".join([i for i in text if i in chars])
|
||||||
|
|
||||||
|
if "%" in s:
|
||||||
|
self.is_perc = True
|
||||||
|
s = s.replace("%", "")
|
||||||
|
else:
|
||||||
|
self.is_perc = False
|
||||||
|
|
||||||
|
if DECIMAL_POINT in s:
|
||||||
|
p = s.find(DECIMAL_POINT)
|
||||||
|
s = s.replace(DECIMAL_POINT, "")
|
||||||
|
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
|
||||||
|
if self.is_perc:
|
||||||
|
s += "%"
|
||||||
|
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.setModified(self.hasFocus())
|
self.setModified(self.hasFocus())
|
||||||
self.setCursorPosition(pos)
|
self.setCursorPosition(pos)
|
||||||
|
|
||||||
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
||||||
return parse_perc_amount(text, DECIMAL_POINT)
|
try:
|
||||||
|
text = text.replace(DECIMAL_POINT, ".")
|
||||||
|
text = text.replace("%", "")
|
||||||
|
return (Decimal)(text)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_text_from_amount(self, amount):
|
def _get_text_from_amount(self, amount):
|
||||||
out = super()._get_text_from_amount(amount)
|
out = super()._get_text_from_amount(amount)
|
||||||
@@ -1138,15 +1395,15 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
if self.base_unit:
|
if self.base_unit:
|
||||||
panel = QStyleOptionFrame()
|
panel = QStyleOptionFrame()
|
||||||
self.initStyleOption(panel)
|
self.initStyleOption(panel)
|
||||||
text_rect = self.style().subElementRect(
|
textRect = self.style().subElementRect(
|
||||||
QStyle.SubElement.SE_LineEditContents, panel, self
|
QStyle.SubElement.SE_LineEditContents, panel, self
|
||||||
)
|
)
|
||||||
text_rect.adjust(2, 0, -10, 0)
|
textRect.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(
|
||||||
text_rect,
|
textRect,
|
||||||
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",
|
||||||
)
|
)
|
||||||
@@ -1225,11 +1482,11 @@ class BalSpinBox(QSpinBox):
|
|||||||
|
|
||||||
|
|
||||||
class WillWidget(QWidget):
|
class WillWidget(QWidget):
|
||||||
def __init__(self, father=None, parent=None, will=None):
|
def __init__(self, father=None, parent=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
vlayout = QVBoxLayout()
|
vlayout = QVBoxLayout()
|
||||||
self.setLayout(vlayout)
|
self.setLayout(vlayout)
|
||||||
self.will = will if will is not None else parent.bal_window.willitems
|
self.will = parent.bal_window.willitems
|
||||||
self._bal_parent = parent
|
self._bal_parent = parent
|
||||||
for w in self.will:
|
for w in self.will:
|
||||||
if (
|
if (
|
||||||
@@ -1256,10 +1513,7 @@ class WillWidget(QWidget):
|
|||||||
willpushbutton = QPushButton(w)
|
willpushbutton = QPushButton(w)
|
||||||
|
|
||||||
willpushbutton.clicked.connect(
|
willpushbutton.clicked.connect(
|
||||||
partial(
|
partial(self._bal_parent.bal_window.show_transaction, txid=w)
|
||||||
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))
|
||||||
@@ -1281,9 +1535,7 @@ 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(
|
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
|
||||||
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:
|
||||||
@@ -1318,6 +1570,6 @@ class WillWidget(QWidget):
|
|||||||
detailw.setPalette(pal)
|
detailw.setPalette(pal)
|
||||||
|
|
||||||
hlayout.addWidget(detailw)
|
hlayout.addWidget(detailw)
|
||||||
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))
|
hlayout.addWidget(WillWidget(w, parent=parent))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,28 +14,15 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
|
|||||||
it with the GUI.
|
it with the GUI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from electrum.util import MyEncoder
|
|
||||||
|
|
||||||
from ...core.checkalive import (
|
|
||||||
CheckAliveError,
|
|
||||||
check_alive_expired,
|
|
||||||
resolve_date_to_check,
|
|
||||||
)
|
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .dialogs import (
|
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget
|
||||||
BalBuildWillDialog,
|
from .dialogs import (BalBlockingWaitingDialog, BalBuildWillDialog, BalDialog,
|
||||||
BalDialog,
|
BalWaitingDialog, BalWizardDialog, WillDetailDialog,
|
||||||
BalWaitingDialog,
|
WillExecutorDialog)
|
||||||
BalWizardDialog,
|
from .lists import HeirListWidget, PreviewList, WillExecutorWidget
|
||||||
WillDetailDialog,
|
|
||||||
WillExecutorDialog,
|
|
||||||
)
|
|
||||||
from .lists import HeirListWidget, PreviewList
|
|
||||||
from .widgets import LockTimeWidget, PercAmountEdit
|
|
||||||
|
|
||||||
|
|
||||||
class BalWindow:
|
class BalWindow:
|
||||||
@@ -131,22 +118,7 @@ class BalWindow:
|
|||||||
for k in keys:
|
for k in keys:
|
||||||
del self.will[k]
|
del self.will[k]
|
||||||
for wid, w in self.willitems.items():
|
for wid, w in self.willitems.items():
|
||||||
d = w.to_dict()
|
self.will[wid] = w.to_dict()
|
||||||
# Store the tx in its serialized form: the wallet DB deep-copies
|
|
||||||
# every value on save (JsonDB.put), and a live Transaction carrying
|
|
||||||
# wallet-derived input info (utxo / script_descriptor, which hold a
|
|
||||||
# threading.RLock) cannot be deep-copied. Serializing to a string
|
|
||||||
# matches how the will is re-read (get_will -> tx_from_any).
|
|
||||||
d["tx"] = str(d["tx"])
|
|
||||||
# Mirror the encoder the wallet DB uses: JsonDB.put returns False
|
|
||||||
# (silently dropping the will) when a value cannot be serialized,
|
|
||||||
# so prove it here and fail loudly instead.
|
|
||||||
try:
|
|
||||||
json.dumps(d, cls=MyEncoder)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"save_willitems: will {wid} is not serializable: {e!r}")
|
|
||||||
raise
|
|
||||||
self.will[wid] = d
|
|
||||||
|
|
||||||
def init_will(self):
|
def init_will(self):
|
||||||
_logger.info("********************init_____will____________**********")
|
_logger.info("********************init_____will____________**********")
|
||||||
@@ -228,61 +200,15 @@ 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))
|
||||||
addr = str(heir[0])
|
heir_address.setText(str(heir[0]))
|
||||||
heir_address.setText(addr)
|
|
||||||
if not is_op_return_address(addr):
|
|
||||||
heir_amount.setText(
|
heir_amount.setText(
|
||||||
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
|
str(Util.decode_amount(heir[1], self.window.get_decimal_point()))
|
||||||
)
|
)
|
||||||
self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
|
self.heir_locktime = LockTimeWidget(self, self.window, heir[2])
|
||||||
else:
|
|
||||||
heir_address.setText("")
|
|
||||||
self.heir_locktime = LockTimeWidget(self, self.window, self.will_settings["locktime"])
|
|
||||||
|
|
||||||
def _update_op_return_from_message():
|
# heir_is_xpub = QCheckBox()
|
||||||
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
|
||||||
@@ -299,15 +225,12 @@ 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(_("Bitcoin address or OP_RETURN: prefix + hex data")), 2, 2)
|
grid.addWidget(HelpButton(_("heir bitcoin address")), 2, 2)
|
||||||
|
|
||||||
grid.addWidget(op_return_amount_label, 3, 0)
|
grid.addWidget(QLabel(_("Amount")), 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:
|
||||||
@@ -321,15 +244,11 @@ 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():
|
||||||
raw_address = heir_address.text()
|
# TODO SAVE HEIR
|
||||||
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(),
|
||||||
raw_address,
|
heir_address.text(),
|
||||||
amount,
|
Util.encode_amount(heir_amount.text(), self.window.get_decimal_point()),
|
||||||
str(self.will_settings["locktime"]),
|
str(self.will_settings["locktime"]),
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
@@ -342,8 +261,6 @@ 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"]
|
||||||
|
|
||||||
@@ -378,12 +295,6 @@ 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):
|
||||||
@@ -407,12 +318,7 @@ class BalWindow:
|
|||||||
|
|
||||||
f = False
|
f = False
|
||||||
for _u, w in self.willexecutors.items():
|
for _u, w in self.willexecutors.items():
|
||||||
if Willexecutors.is_selected(
|
if Willexecutors.is_selected(w):
|
||||||
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")
|
||||||
@@ -422,20 +328,12 @@ 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. The available-UTXO view
|
# no per-mode branch is needed here anymore.
|
||||||
# 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"],
|
||||||
Util.get_available_utxos(
|
None,
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -471,81 +369,23 @@ class BalWindow:
|
|||||||
return self.willitems
|
return self.willitems
|
||||||
|
|
||||||
def check_will(self):
|
def check_will(self):
|
||||||
result = Will.is_will_valid(
|
return 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"],
|
||||||
Util.get_available_utxos(
|
self.window.wallet.get_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, title=None):
|
def show_warning(self, text, parent=None):
|
||||||
self.window.show_warning(text, parent=parent, title=title)
|
self.window.show_warning(text, parent=None)
|
||||||
|
|
||||||
def show_error(self, text):
|
def show_error(self, text):
|
||||||
self.window.show_error(text)
|
self.window.show_error(text)
|
||||||
@@ -637,13 +477,11 @@ class BalWindow:
|
|||||||
# against the current moment, i.e. the Check Alive effectively does
|
# against the current moment, i.e. the Check Alive effectively does
|
||||||
# not exist, while the delivery time (locktime) is still fully
|
# not exist, while the delivery time (locktime) is still fully
|
||||||
# enforced. ADVANCED mode keeps the user-controlled Check Alive
|
# enforced. ADVANCED mode keeps the user-controlled Check Alive
|
||||||
# exactly as before. The policy itself lives in
|
# exactly as before.
|
||||||
# ``bal.core.checkalive.resolve_date_to_check``.
|
if self.bal_plugin.is_basic_mode():
|
||||||
self.date_to_check = resolve_date_to_check(
|
self.date_to_check = datetime.now().timestamp()
|
||||||
self.bal_plugin.is_basic_mode(),
|
else:
|
||||||
self.will_settings,
|
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
|
||||||
built_locktime=Will.get_min_locktime(self.willitems),
|
|
||||||
)
|
|
||||||
# found = False
|
# found = False
|
||||||
# NOTE: block-height tracking removed (A1) - locktimes are always
|
# NOTE: block-height tracking removed (A1) - locktimes are always
|
||||||
# UNIX timestamps now, so we no longer read the current block height
|
# UNIX timestamps now, so we no longer read the current block height
|
||||||
@@ -660,10 +498,9 @@ class BalWindow:
|
|||||||
# flow. In BASIC we therefore SKIP this check entirely, so a passed
|
# flow. In BASIC we therefore SKIP this check entirely, so a passed
|
||||||
# check-alive date never forces a postpone/rewrite of the will. The
|
# check-alive date never forces a postpone/rewrite of the will. The
|
||||||
# delivery time (locktime) is unaffected and still fully enforced.
|
# delivery time (locktime) is unaffected and still fully enforced.
|
||||||
# The BASIC/ADVANCED rule lives in
|
if (
|
||||||
# ``bal.core.checkalive.check_alive_expired``.
|
not self.bal_plugin.is_basic_mode()
|
||||||
if check_alive_expired(
|
and self.date_to_check < datetime.now().timestamp()
|
||||||
self.bal_plugin.is_basic_mode(), self.date_to_check
|
|
||||||
):
|
):
|
||||||
raise CheckAliveError(self.date_to_check)
|
raise CheckAliveError(self.date_to_check)
|
||||||
|
|
||||||
@@ -692,16 +529,9 @@ class BalWindow:
|
|||||||
Will.check_amounts(
|
Will.check_amounts(
|
||||||
self.heirs,
|
self.heirs,
|
||||||
self.willexecutors,
|
self.willexecutors,
|
||||||
Util.get_available_utxos(
|
self.window.wallet.get_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(
|
||||||
@@ -709,11 +539,6 @@ 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(
|
||||||
_(
|
_(
|
||||||
@@ -728,12 +553,7 @@ 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(
|
if Willexecutors.is_selected(we):
|
||||||
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(
|
||||||
@@ -844,7 +664,8 @@ class BalWindow:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self._schedule_history_refresh()
|
self.window.history_list.update()
|
||||||
|
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
|
||||||
@@ -914,7 +735,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, will=None):
|
def invalidate_will(self):
|
||||||
def on_success(result):
|
def on_success(result):
|
||||||
if result:
|
if result:
|
||||||
self.show_message(
|
self.show_message(
|
||||||
@@ -929,29 +750,18 @@ 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)
|
log_error(exec_info, self.bal_window)
|
||||||
|
|
||||||
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(
|
task = partial(Will.invalidate_will, self.willitems, self.wallet, fee_per_byte)
|
||||||
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, will=None, txids=None):
|
def sign_transactions(self, password):
|
||||||
try:
|
try:
|
||||||
willitems = will if will is not None else self.willitems
|
|
||||||
txs = {}
|
txs = {}
|
||||||
signed = None
|
signed = None
|
||||||
tosign = None
|
tosign = None
|
||||||
@@ -962,21 +772,9 @@ class BalWindow:
|
|||||||
msg = _(f"signed: {signed}\n")
|
msg = _(f"signed: {signed}\n")
|
||||||
return msg + _(f"signing: {tosign}")
|
return msg + _(f"signing: {tosign}")
|
||||||
|
|
||||||
if txids is not None:
|
for txid in Will.only_valid(self.willitems):
|
||||||
targets = [
|
wi = self.willitems[txid]
|
||||||
t for t in txids
|
tx = copy.deepcopy(wi.tx)
|
||||||
if t in willitems and willitems[t].get_status("VALID")
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
targets = Will.only_valid(willitems)
|
|
||||||
for txid in targets:
|
|
||||||
wi = willitems[txid]
|
|
||||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
|
||||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
|
||||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
|
||||||
# from the serialized form instead, which is exactly how the will
|
|
||||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
|
||||||
tx = Will.get_tx_from_any(str(wi.tx))
|
|
||||||
if wi.get_status("COMPLETE"):
|
if wi.get_status("COMPLETE"):
|
||||||
txs[txid] = tx
|
txs[txid] = tx
|
||||||
continue
|
continue
|
||||||
@@ -987,8 +785,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 willitems:
|
if prevout[0] in self.willitems:
|
||||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
change = self.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
|
||||||
@@ -1005,16 +803,6 @@ 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
|
||||||
@@ -1081,32 +869,16 @@ 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, will=None, txids=None):
|
def ask_password_and_sign_transactions(self, callback=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():
|
||||||
# Re-parse instead of deepcopy: the signed tx may carry
|
self.willitems[txid].tx = copy.deepcopy(tx)
|
||||||
# wallet-derived input info holding a threading.RLock, which
|
self.will[txid] = self.willitems[txid].to_dict()
|
||||||
# copy.deepcopy cannot pickle (see sign_transactions above).
|
|
||||||
willitems[txid].tx = Will.get_tx_from_any(str(tx))
|
|
||||||
if not external:
|
|
||||||
self.will[txid] = willitems[txid].to_dict()
|
|
||||||
try:
|
|
||||||
Will.check_signatures(willitems, self.wallet)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"check_signatures after signing failed: {e}")
|
|
||||||
if not external:
|
|
||||||
try:
|
try:
|
||||||
self.will_list_widget.update()
|
self.will_list_widget.update()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# After signing, keep the local history in sync (save the still
|
|
||||||
# incomplete "New" txs, remove the now-complete ones).
|
|
||||||
if not external:
|
|
||||||
self._save_will_to_history()
|
|
||||||
if callback:
|
if callback:
|
||||||
try:
|
try:
|
||||||
callback()
|
callback()
|
||||||
@@ -1114,21 +886,18 @@ class BalWindow:
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
def on_failure(exec_info):
|
def on_failure(exec_info):
|
||||||
log_error(exec_info, self)
|
log_error(exec_info, self.bal_window)
|
||||||
|
|
||||||
password = self.get_wallet_password()
|
password = self.get_wallet_password()
|
||||||
task = partial(self.sign_transactions, password, will=will, txids=txids)
|
task = partial(self.sign_transactions, password)
|
||||||
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, will=None, txids=None):
|
def broadcast_transactions(self, force=False):
|
||||||
external = will is not None
|
|
||||||
|
|
||||||
def on_success(sulcess):
|
def on_success(sulcess):
|
||||||
if not external:
|
|
||||||
self.will_list_widget.update()
|
self.will_list_widget.update()
|
||||||
if sulcess:
|
if sulcess:
|
||||||
_logger.info("error, some transaction was not sent")
|
_logger.info("error, some transaction was not sent")
|
||||||
@@ -1140,7 +909,7 @@ class BalWindow:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def on_failure(exec_info):
|
def on_failure(exec_info):
|
||||||
log_error(exec_info, self)
|
log_error(exec_info, self.bal_window)
|
||||||
# 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}")
|
||||||
@@ -1154,20 +923,15 @@ 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, will=will, txids=txids)
|
task = partial(self.push_transactions_to_willexecutors, force)
|
||||||
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, will=None, txids=None):
|
def push_transactions_to_willexecutors(self, force=False):
|
||||||
willitems = will if will is not None else self.willitems
|
willexecutors = Willexecutors.get_willexecutor_transactions(self.willitems, force=force)
|
||||||
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"
|
||||||
@@ -1196,11 +960,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", []):
|
||||||
willitems[wid].set_status("PUSHED", True)
|
self.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", []):
|
||||||
willitems[wid].set_status("PUSH_FAIL", True)
|
self.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)
|
||||||
@@ -1225,191 +989,54 @@ class BalWindow:
|
|||||||
return
|
return
|
||||||
self.waiting_dialog.update(
|
self.waiting_dialog.update(
|
||||||
"checking {} - {} : {}".format(
|
"checking {} - {} : {}".format(
|
||||||
willitems[wid].we["url"], wid, "Waiting"
|
self.willitems[wid].we["url"], wid, "Waiting"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
w = willitems[wid]
|
w = self.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(
|
||||||
willitems[wid].we["url"],
|
self.willitems[wid].we["url"],
|
||||||
wid,
|
wid,
|
||||||
willitems[wid].get_status("CHECKED"),
|
self.willitems[wid].get_status("CHECKED"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if error["flag"]:
|
if error["flag"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def export_json_file(self, path, will=None):
|
def export_json_file(self, path):
|
||||||
if will is None:
|
|
||||||
for wid in self.willitems:
|
for wid in self.willitems:
|
||||||
self.willitems[wid].set_status("EXPORTED", True)
|
self.willitems[wid].set_status("EXPORTED", True)
|
||||||
self.will[wid] = self.willitems[wid].to_dict()
|
self.will[wid] = self.willitems[wid].to_dict()
|
||||||
write_json_file(path, self.will)
|
write_json_file(path, self.will)
|
||||||
else:
|
|
||||||
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
|
|
||||||
|
|
||||||
def export_will(self, will=None):
|
def export_will(self):
|
||||||
try:
|
try:
|
||||||
export_meta_gui(
|
export_meta_gui(self.window, "will.json", self.export_json_file)
|
||||||
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 merge_will(self, imported):
|
def import_will(self):
|
||||||
"""Merge imported will items into the live will.
|
def sulcess():
|
||||||
|
|
||||||
Both the tools-menu "Merge" action and the details-dialog "Merge"
|
|
||||||
button go through this single method.
|
|
||||||
|
|
||||||
For a transaction that already exists in the live will the live
|
|
||||||
WillItem is kept (never replaced): only the operational statuses
|
|
||||||
(signed/pushed/checked/mempool/confirmed) that are True in the
|
|
||||||
imported item are carried over. When the live transaction is not
|
|
||||||
yet signed the imported transaction is merged into it (signatures
|
|
||||||
are combined when both are the same unsigned tx, otherwise the
|
|
||||||
transaction is substituted); an already-signed live transaction is
|
|
||||||
left untouched. Transactions that are new are added wholesale.
|
|
||||||
|
|
||||||
After the merge a local validity check recomputes the
|
|
||||||
valid/invalidated/replaced statuses (no server contact, no expiry
|
|
||||||
raise).
|
|
||||||
"""
|
|
||||||
# The reference timestamp is normally set by init_class_variables(),
|
|
||||||
# which the merge flow does not run (Merge -> file import can be the
|
|
||||||
# very first action in a session). Fall back to "now" so the local
|
|
||||||
# validity check and the trailing update_all() always have it.
|
|
||||||
if not hasattr(self, "date_to_check") or self.date_to_check is None:
|
|
||||||
self.date_to_check = resolve_date_to_check(
|
|
||||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
|
||||||
)
|
|
||||||
|
|
||||||
for wid, wi in imported.items():
|
|
||||||
if wid in self.willitems:
|
|
||||||
live = self.willitems[wid]
|
|
||||||
was_complete = live.get_status("COMPLETE")
|
|
||||||
for status in (
|
|
||||||
"COMPLETE",
|
|
||||||
"PUSHED",
|
|
||||||
"CHECKED",
|
|
||||||
"MEMPOOL",
|
|
||||||
"CONFIRMED",
|
|
||||||
):
|
|
||||||
if wi.get_status(status):
|
|
||||||
live.set_status(status, True)
|
|
||||||
if not was_complete:
|
|
||||||
try:
|
|
||||||
if live.tx.txid() == wi.tx.txid():
|
|
||||||
live.tx.combine_with_other_psbt(wi.tx)
|
|
||||||
else:
|
|
||||||
live.tx = wi.tx
|
|
||||||
except Exception:
|
|
||||||
live.tx = wi.tx
|
|
||||||
if live.tx.is_complete():
|
|
||||||
live.set_status("COMPLETE", True)
|
|
||||||
else:
|
|
||||||
self.willitems[wid] = wi
|
|
||||||
Will.normalize_will(self.willitems, self.wallet)
|
|
||||||
self.save_willitems()
|
|
||||||
# Local validity check: recompute valid/invalidated/replaced statuses.
|
|
||||||
try:
|
|
||||||
Will.add_willtree(self.willitems)
|
|
||||||
bal_plugin = getattr(self, "bal_plugin", None)
|
|
||||||
history_label = (
|
|
||||||
bal_plugin.HISTORY_LABEL.get() if bal_plugin is not None else None
|
|
||||||
)
|
|
||||||
all_utxos = Util.get_available_utxos(
|
|
||||||
self.wallet,
|
|
||||||
history_label,
|
|
||||||
Will.get_min_locktime(
|
|
||||||
self.willitems, default_value=self.date_to_check
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Will.check_invalidated(
|
|
||||||
self.willitems, Will.utxos_strs(all_utxos), self.wallet
|
|
||||||
)
|
|
||||||
Will.search_rai(
|
|
||||||
Will.get_all_inputs(self.willitems, only_valid=True),
|
|
||||||
all_utxos,
|
|
||||||
self.willitems,
|
|
||||||
self.wallet,
|
|
||||||
)
|
|
||||||
Will.check_signatures(self.willitems, self.wallet)
|
|
||||||
except Exception as e:
|
|
||||||
log_error(e, self)
|
|
||||||
self.save_willitems()
|
|
||||||
self.update_all()
|
|
||||||
|
|
||||||
def merge_will_from_file(self, path):
|
|
||||||
try:
|
|
||||||
willitems = self._load_will_file(path)
|
|
||||||
except Exception as e:
|
|
||||||
raise FileImportFailed(_("Invalid will file: {}").format(e)) from None
|
|
||||||
Will.normalize_will(willitems, self.wallet)
|
|
||||||
self.merge_will(willitems)
|
|
||||||
|
|
||||||
def merge_single_transaction(self, tx):
|
|
||||||
"""Merge a single raw transaction (e.g. from the clipboard or a file)
|
|
||||||
into the live will.
|
|
||||||
|
|
||||||
The transaction is wrapped in a fresh :class:`WillItem` and merged
|
|
||||||
through :meth:`merge_will`, so existing items are combined/updated and
|
|
||||||
new transactions are added wholesale, exactly like a will-file merge.
|
|
||||||
"""
|
|
||||||
wi = WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=self.wallet)
|
|
||||||
self.merge_will({wi._id: wi})
|
|
||||||
|
|
||||||
def merge_will_ui(self):
|
|
||||||
def on_success():
|
|
||||||
self.will_list_widget.update_will(self.willitems)
|
self.will_list_widget.update_will(self.willitems)
|
||||||
|
|
||||||
import_meta_gui(self.window, _("will"), self.merge_will_from_file, on_success)
|
import_meta_gui(self.window, _("will"), self.import_json_file, sulcess)
|
||||||
|
|
||||||
def import_will_into_details(self):
|
def import_json_file(self, path):
|
||||||
"""Import a will file and show it in a WillDetails window.
|
|
||||||
|
|
||||||
Unlike the "Merge" actions (which merge the file into the active
|
|
||||||
will), this is a read-only preview: the parsed will is shown in a
|
|
||||||
:class:`WillDetailDialog` and the live wallet state is never touched.
|
|
||||||
The dialog's Sign/Broadcast/Export/Invalidate buttons operate on the
|
|
||||||
imported will only, and its Merge button merges the imported will
|
|
||||||
into the live one.
|
|
||||||
"""
|
|
||||||
imported = {}
|
|
||||||
|
|
||||||
def on_file(path):
|
|
||||||
try:
|
try:
|
||||||
willitems = self._load_will_file(path)
|
|
||||||
except Exception as e:
|
|
||||||
self.show_error(_("Invalid will file: {}").format(e))
|
|
||||||
return
|
|
||||||
# Attach wallet/input info so the imported txs can be signed and
|
|
||||||
# broadcast (mirrors what merge_will_from_file does).
|
|
||||||
Will.normalize_will(willitems, self.wallet)
|
|
||||||
for wi in willitems.values():
|
|
||||||
wi.set_status("IMPORTED", True)
|
|
||||||
imported.update(willitems)
|
|
||||||
|
|
||||||
def on_success():
|
|
||||||
if not imported:
|
|
||||||
return
|
|
||||||
d = WillDetailDialog(self, will=imported)
|
|
||||||
show_on_top(d)
|
|
||||||
|
|
||||||
import_meta_gui(self.window, _("will"), on_file, on_success)
|
|
||||||
|
|
||||||
def _load_will_file(self, path):
|
|
||||||
data = read_json_file(path)
|
data = read_json_file(path)
|
||||||
willitems = {}
|
willitems = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
data[k]["tx"] = tx_from_any(v["tx"])
|
data[k]["tx"] = tx_from_any(v["tx"])
|
||||||
willitems[k] = WillItem(data[k], _id=k)
|
willitems[k] = WillItem(data[k], _id=k)
|
||||||
return willitems
|
self.update_will(willitems)
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
# raise FileImportFailed(_("Invalid will file"))
|
||||||
|
|
||||||
def check_transactions_task(self, will):
|
def check_transactions_task(self, will):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -1769,15 +1396,14 @@ 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")
|
||||||
@@ -1805,13 +1431,7 @@ 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 = Util.get_available_utxos(
|
all_utxos = self.wallet.get_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,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "bal",
|
"name": "bal",
|
||||||
"fullname": "Bitcoin After Life",
|
"fullname": "Bitcoin After Life",
|
||||||
"version": "0.6.1",
|
"version": "0.5.18",
|
||||||
"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": [
|
"available_for": ["qt"],
|
||||||
"qt"
|
|
||||||
],
|
|
||||||
"icon": "icons/bal32x32.png"
|
"icon": "icons/bal32x32.png"
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,11 @@
|
|||||||
|
|
||||||
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 any forge (Gitea/GitHub) and in any browser —
|
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**.
|
||||||
**no PDF needed**.
|
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
| Document | Markdown | Styled HTML |
|
| Document | Markdown (GitHub) | 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) |
|
||||||
@@ -24,8 +23,9 @@ HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
|
|||||||
|
|
||||||
## Viewing the HTML versions
|
## Viewing the HTML versions
|
||||||
|
|
||||||
- Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
|
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy
|
||||||
GitHub) and open `manual/manual.html`.
|
from branch, folder `/docs`), then open
|
||||||
|
`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,7 +43,6 @@ 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 |
|
||||||
@@ -66,7 +65,6 @@ 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
|
||||||
|
|
||||||
@@ -85,8 +83,7 @@ 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 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
|
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
||||||
| 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*
|
||||||
@@ -357,5 +354,5 @@ that limit.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This document reflects the current BAL plugin (v0.6.1). Behaviour is derived
|
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
||||||
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
`core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
||||||
|
|||||||
@@ -76,22 +76,6 @@ 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
|
||||||
@@ -110,11 +94,6 @@ 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.
|
||||||
|
|
||||||
@@ -244,9 +223,6 @@ 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
|
||||||
@@ -263,8 +239,7 @@ 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. *(The Check Alive only exists in **ADVANCED** mode; in
|
previous month.
|
||||||
**BASIC** this is not needed.)*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -360,7 +335,7 @@ inheritance:
|
|||||||
|
|
||||||
## Will‑Executor service list
|
## Will‑Executor service list
|
||||||
|
|
||||||
This window opens from the Electrum menu, **Tools → Will‑Executors**, and shows
|
This window opens from the Electrum menu, **Tools → Will‑executor**, 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 —
|
||||||
@@ -434,16 +409,14 @@ 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 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
|
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||||
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
| 3 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||||
| 4 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
| 4 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
||||||
| 5 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
||||||
| 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
||||||
| 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
| 7 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
||||||
| 8 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||||
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
| 9 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||||
| 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
285
make-release.sh
@@ -1,285 +0,0 @@
|
|||||||
#!/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}"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-----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,8 +33,7 @@ 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 = _S(); s.visit(tree)
|
||||||
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))
|
||||||
@@ -46,13 +45,12 @@ 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()
|
w = QWidget(); child = QWidget(w)
|
||||||
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")
|
||||||
|
|||||||
3937
tests/karen7
3937
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")
|
||||||
we_cls = we_mod.Willexecutors
|
W = 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 = we_cls.get_info_task
|
orig_get_info = W.get_info_task
|
||||||
we_cls.get_info_task = staticmethod(slow_get_info)
|
W.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()
|
||||||
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
W.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:
|
||||||
we_cls.get_info_task = orig_get_info
|
W.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 = we_cls.push_transactions_to_willexecutor
|
orig_push = W.push_transactions_to_willexecutor
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
|
W.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 = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
|
results = W.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:
|
||||||
we_cls.push_transactions_to_willexecutor = orig_push
|
W.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 = we_cls.push_transactions_to_willexecutor
|
orig_push2 = W.push_transactions_to_willexecutor
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
W.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
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
W.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()
|
||||||
we_cls.push_transactions_parallel(
|
W.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:
|
||||||
we_cls.push_transactions_to_willexecutor = orig_push2
|
W.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 = we_cls.push_transactions_to_willexecutor
|
orig_push3 = W.push_transactions_to_willexecutor
|
||||||
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
W.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())
|
||||||
|
|
||||||
we_cls.push_transactions_parallel(
|
W.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:
|
||||||
we_cls.push_transactions_to_willexecutor = orig_push3
|
W.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 = we_cls.check_transaction
|
orig_check = W.check_transaction
|
||||||
we_cls.check_transaction = staticmethod(slow_check)
|
W.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 = we_cls.check_transactions_parallel(
|
results = W.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:
|
||||||
we_cls.check_transaction = orig_check
|
W.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 = we_cls.check_transaction
|
orig_check2 = W.check_transaction
|
||||||
we_cls.check_transaction = staticmethod(hanging_check)
|
W.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()
|
||||||
we_cls.check_transactions_parallel(
|
W.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:
|
||||||
we_cls.check_transaction = orig_check2
|
W.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,15 +17,10 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QPushButton,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
|
||||||
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,17 +20,12 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtCore import QSize # noqa: E402
|
|
||||||
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
|
||||||
QComboBox,
|
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
|
||||||
QPushButton,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
||||||
|
from PyQt6.QtCore import QSize, Qt # noqa: E402
|
||||||
|
|
||||||
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,19 +27,12 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
|
||||||
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
QApplication,
|
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
|
||||||
QComboBox,
|
QLineEdit, QSpinBox, QLabel,
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QLineEdit,
|
|
||||||
QSpinBox,
|
|
||||||
QToolButton,
|
|
||||||
QVBoxLayout,
|
|
||||||
QWidget,
|
|
||||||
)
|
)
|
||||||
|
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
||||||
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _char_w():
|
def _char_w():
|
||||||
|
|||||||
792
tests/samanta7
792
tests/samanta7
File diff suppressed because one or more lines are too long
@@ -21,21 +21,18 @@ 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 copy
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import copy
|
||||||
|
|
||||||
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 (
|
||||||
HeirNotFoundException,
|
WillItem, Will,
|
||||||
NoHeirsException,
|
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
|
||||||
NotCompleteWillException,
|
TxFeesChangedException, WillExpiredException,
|
||||||
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,28 +26,30 @@ 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).
|
||||||
bal_timestamp = bal.BalTimestamp
|
BalTimestamp = bal.BalTimestamp
|
||||||
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
||||||
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
||||||
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
|
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
|
||||||
|
|
||||||
util_cls = util.Util
|
Util = util.Util
|
||||||
assert util_cls.is_perc("50%") is True
|
assert Util.is_perc("50%") is True
|
||||||
assert util_cls.is_perc("100") is False
|
assert Util.is_perc("100") is False
|
||||||
assert util_cls.text_to_hex("BAL") == "42414c"
|
assert Util.text_to_hex("BAL") == "42414c"
|
||||||
assert util_cls.hex_to_text("42414c") == "BAL"
|
assert Util.hex_to_text("42414c") == "BAL"
|
||||||
assert util_cls.int_locktime(days=1) == 86400
|
assert Util.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 copy
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import copy
|
||||||
|
|
||||||
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 # pyright: ignore[reportMissingImports]
|
import pytest # noqa: E402
|
||||||
|
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
NotCompleteWillException,
|
|
||||||
Will,
|
|
||||||
WillExpiredException,
|
|
||||||
WillItem,
|
WillItem,
|
||||||
|
Will,
|
||||||
|
NotCompleteWillException,
|
||||||
|
WillExpiredException,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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 os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
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 LOCKTIME_THRESHOLD, Util
|
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``bal.core.checkalive`` (pure, GUI-free).
|
|
||||||
|
|
||||||
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
|
|
||||||
policy (``resolve_date_to_check`` / ``check_alive_expired``).
|
|
||||||
|
|
||||||
Run:
|
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
|
||||||
python3 tests/test_core_checkalive.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
|
||||||
|
|
||||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
|
||||||
CheckAliveError,
|
|
||||||
check_alive_expired,
|
|
||||||
resolve_date_to_check,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# CheckAliveError
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
|
||||||
err = CheckAliveError(1000000)
|
|
||||||
assert err.timestamp_to_check == 1000000
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
|
||||||
err = CheckAliveError(1000000)
|
|
||||||
s = str(err)
|
|
||||||
assert "Check alive expired" in s
|
|
||||||
assert "1970" in s
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
|
||||||
assert issubclass(CheckAliveError, Exception)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# resolve_date_to_check
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_uses_now():
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_uses_threshold_absolute():
|
|
||||||
threshold = time.time() + 5 * 86400
|
|
||||||
settings = {"threshold": threshold}
|
|
||||||
result = resolve_date_to_check(False, settings)
|
|
||||||
assert abs(result - threshold) < 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_parses_relative_threshold():
|
|
||||||
# A relative threshold means "N days BEFORE the delivery": it resolves
|
|
||||||
# against the stored locktime (backwards), not forward from now.
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
locktime = fake_now + 90 * 86400
|
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
# date_to_check = (locktime, midnight-normalised) - 30 days.
|
|
||||||
expected = (datetime.fromtimestamp(locktime)
|
|
||||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
- timedelta(days=30)).timestamp()
|
|
||||||
assert abs(result - expected) < 1
|
|
||||||
# 90d delivery with a 30d window: the window starts 60 days after now.
|
|
||||||
assert result > fake_now
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_anchored_to_locktime():
|
|
||||||
"""A relative threshold never drifts with the clock: re-resolving it a day
|
|
||||||
later, with the same fixed absolute locktime, yields the same date."""
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
locktime = fake_now + 90 * 86400
|
|
||||||
settings = {"threshold": "30d", "locktime": locktime}
|
|
||||||
first = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
# Next day: same stored settings (the fixed delivery), a later clock.
|
|
||||||
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
|
||||||
assert first == second
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_with_relative_locktime():
|
|
||||||
"""A relative locktime is resolved against 'now' first, then the relative
|
|
||||||
threshold counts N days back from it (matches the settings widget)."""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
# Recompute the expected value with the same resolution rules:
|
|
||||||
# locktime = now + 90d (midnight-normalised), threshold = locktime - 30d.
|
|
||||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
|
|
||||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
assert abs(result - expected) < 1
|
|
||||||
assert result > fake_now
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_threshold_no_locktime_falls_back():
|
|
||||||
# Without a locktime reference, fall back to the legacy forward resolution.
|
|
||||||
settings = {"threshold": "30d"}
|
|
||||||
result = resolve_date_to_check(False, settings)
|
|
||||||
assert result > time.time()
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_locktime_anchored_to_built_tx():
|
|
||||||
"""A RELATIVE stored locktime is anchored to the built will's frozen
|
|
||||||
delivery date (built_locktime), not to "now": an unchanged will must not
|
|
||||||
read as expired as the clock advances (the karen7 daily-invalidate bug)."""
|
|
||||||
frozen = 1817438400 # frozen tx locktime (2027-08-05), built 2026-08-05
|
|
||||||
settings = {"threshold": "30d", "locktime": "2y"}
|
|
||||||
# On build day the frozen delivery is authoritative: date_to_check is
|
|
||||||
# frozen - 30d and NEVER drifts, however much later the clock gets.
|
|
||||||
first = resolve_date_to_check(
|
|
||||||
False, settings, now=1_800_000_000.0, built_locktime=frozen
|
|
||||||
)
|
|
||||||
assert abs(first - (frozen - 30 * 86400)) < 1
|
|
||||||
later = resolve_date_to_check(
|
|
||||||
False, settings, now=1_800_000_000.0 + 10 * 86400, built_locktime=frozen
|
|
||||||
)
|
|
||||||
assert first == later
|
|
||||||
# The check window must start BEFORE the frozen delivery (never expired).
|
|
||||||
assert first < frozen
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
|
||||||
"""Without a built will there is no anchor: keeps the legacy now-based
|
|
||||||
resolution (a moving target, used only before the first build)."""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp
|
|
||||||
|
|
||||||
fake_now = 1_800_000_000.0
|
|
||||||
settings = {"threshold": "30d", "locktime": "90d"}
|
|
||||||
result = resolve_date_to_check(False, settings, now=fake_now)
|
|
||||||
locktime_dt = BalTimestamp("90d").to_date(datetime.fromtimestamp(fake_now))
|
|
||||||
expected = BalTimestamp("30d").to_date(locktime_dt, reverse=True).timestamp()
|
|
||||||
assert abs(result - expected) < 1
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# check_alive_expired
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_never_expired():
|
|
||||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
|
||||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_expired_when_past():
|
|
||||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_not_expired_when_future():
|
|
||||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All core checkalive tests passed")
|
|
||||||
@@ -9,34 +9,23 @@ Run:
|
|||||||
python3 tests/test_core_heirs.py
|
python3 tests/test_core_heirs.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
HEIR_ADDRESS,
|
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||||
HEIR_AMOUNT,
|
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
||||||
HEIR_DUST_AMOUNT,
|
|
||||||
HEIR_LOCKTIME,
|
|
||||||
HEIR_REAL_AMOUNT,
|
|
||||||
OP_RETURN_PREFIX,
|
|
||||||
TRANSACTION_LABEL,
|
|
||||||
AliasNotFoundException,
|
|
||||||
AmountNotValid,
|
|
||||||
BalanceTooLowException,
|
|
||||||
HeirAmountIsDustException,
|
|
||||||
Heirs,
|
|
||||||
LocktimeNotValid,
|
|
||||||
NoHeirsException,
|
|
||||||
NotAnAddress,
|
|
||||||
WillExecutorFeeException,
|
|
||||||
create_op_return_script,
|
create_op_return_script,
|
||||||
get_op_return_hex,
|
AliasNotFoundException,
|
||||||
is_op_return_address,
|
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
||||||
validate_op_return_hex,
|
HeirExpiredException, HeirAmountIsDustException,
|
||||||
|
NoHeirsException, WillExecutorFeeException,
|
||||||
|
BalanceTooLowException,
|
||||||
|
Heirs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Constants
|
# Constants
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -79,7 +68,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
|
||||||
raise AssertionError("expected ValueError")
|
assert False, "expected ValueError"
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -188,13 +177,13 @@ def test_validate_amount():
|
|||||||
# Invalid
|
# Invalid
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("0.000000001")
|
Heirs.validate_amount("0.000000001")
|
||||||
raise AssertionError("expected AmountNotValid")
|
assert False, "expected AmountNotValid"
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("-1")
|
Heirs.validate_amount("-1")
|
||||||
raise AssertionError("expected AmountNotValid")
|
assert False, "expected AmountNotValid"
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -218,7 +207,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)
|
||||||
raise AssertionError("expected LocktimeNotValid")
|
assert False, "expected LocktimeNotValid"
|
||||||
except LocktimeNotValid:
|
except LocktimeNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -269,99 +258,9 @@ 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("[OK] All heirs tests passed")
|
print(f"[OK] All heirs tests passed")
|
||||||
|
|||||||
@@ -8,20 +8,19 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
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 (
|
||||||
HEIR_AMOUNT,
|
Heirs, create_op_return_script, reduce_outputs,
|
||||||
Heirs,
|
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||||
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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -140,7 +139,6 @@ 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:
|
||||||
@@ -167,7 +165,6 @@ 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:
|
||||||
@@ -191,7 +188,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")
|
||||||
raise AssertionError("should have raised")
|
assert False, "should have raised"
|
||||||
except NotAnAddress:
|
except NotAnAddress:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``bal.core.input_rules`` (pure, GUI-free).
|
|
||||||
|
|
||||||
Covers the locktime acceptance bounds, the RAW locktime sanitisation, and the
|
|
||||||
percentage-or-amount field normalisation that the Qt editors wrap.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
|
||||||
python3 tests/test_core_input_rules.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
|
||||||
|
|
||||||
from bal.core.input_rules import ( # noqa: E402 (path insert above)
|
|
||||||
LockTimeEditor,
|
|
||||||
normalize_locktime_raw_text,
|
|
||||||
normalize_perc_amount_text,
|
|
||||||
parse_perc_amount,
|
|
||||||
replace_dy_suffixes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# LockTimeEditor
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_editor_is_acceptable():
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime(100) is True
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime(0) is True
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime(-1) is False
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime(None) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_editor_is_acceptable_string():
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime("100") is True
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime("abc") is False
|
|
||||||
assert LockTimeEditor.is_acceptable_locktime("") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_editor_min_max():
|
|
||||||
assert LockTimeEditor.min_allowed_value >= 0
|
|
||||||
assert LockTimeEditor.max_allowed_value > LockTimeEditor.min_allowed_value
|
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_editor_get_max_allowed_timestamp():
|
|
||||||
# Always a valid, fromtimestamp-able value (the Windows 2038 clamp).
|
|
||||||
ts = LockTimeEditor.get_max_allowed_timestamp()
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
datetime.datetime.fromtimestamp(ts) # must not raise
|
|
||||||
assert ts <= 2**32 - 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_editor_subclass_bounds():
|
|
||||||
class Tight(LockTimeEditor):
|
|
||||||
min_allowed_value = 1000
|
|
||||||
max_allowed_value = 2000
|
|
||||||
|
|
||||||
assert Tight.is_acceptable_locktime(1500) is True
|
|
||||||
assert Tight.is_acceptable_locktime(999) is False
|
|
||||||
assert Tight.is_acceptable_locktime(2001) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# replace_dy_suffixes / RAW locktime sanitisation
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_replace_dy_suffixes():
|
|
||||||
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
|
||||||
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
|
||||||
# anymore (locktimes are always UNIX timestamps now).
|
|
||||||
assert replace_dy_suffixes("123d") == "123"
|
|
||||||
assert replace_dy_suffixes("456y") == "456"
|
|
||||||
# "b" is left untouched (no longer a recognised suffix)
|
|
||||||
assert replace_dy_suffixes("789b") == "789b"
|
|
||||||
# only d/y are stripped; a stray "b" remains
|
|
||||||
assert replace_dy_suffixes("12d34y56b") == "123456b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_locktime_raw_empty():
|
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text("", 0)
|
|
||||||
assert s == ""
|
|
||||||
assert isdays is False
|
|
||||||
assert isyears is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_locktime_raw_days():
|
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text("30d", 3)
|
|
||||||
assert s == "30d"
|
|
||||||
assert isdays is True
|
|
||||||
assert isyears is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_locktime_raw_years():
|
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text("2y", 2)
|
|
||||||
assert s == "2y"
|
|
||||||
assert isdays is False
|
|
||||||
assert isyears is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_locktime_raw_strips_bad_chars():
|
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text("12a34b56", 8)
|
|
||||||
assert s == "123456"
|
|
||||||
assert isdays is False
|
|
||||||
assert isyears is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_locktime_raw_single_suffix():
|
|
||||||
# "1y30d" collapses to a single suffix; "d" wins (processed first), so the
|
|
||||||
# "y" is dropped along with all letters.
|
|
||||||
s, isdays, isyears, pos = normalize_locktime_raw_text("1y30d", 5)
|
|
||||||
assert isdays is True
|
|
||||||
assert isyears is False
|
|
||||||
assert s == "130d"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Percentage-or-amount normalisation
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_perc_normalize_percent():
|
|
||||||
s, is_perc = normalize_perc_amount_text("50%", ".")
|
|
||||||
assert is_perc is True
|
|
||||||
assert s == "50%"
|
|
||||||
|
|
||||||
|
|
||||||
def test_perc_normalize_no_percent():
|
|
||||||
s, is_perc = normalize_perc_amount_text("123", ".")
|
|
||||||
assert is_perc is False
|
|
||||||
assert s == "123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_perc_normalize_strips_invalid():
|
|
||||||
s, is_perc = normalize_perc_amount_text("1a2b3", ".")
|
|
||||||
assert s == "123"
|
|
||||||
|
|
||||||
|
|
||||||
def test_perc_normalize_decimal_limit():
|
|
||||||
# At most 8 decimals after the point.
|
|
||||||
s, is_perc = normalize_perc_amount_text("1.1234567890123", ".")
|
|
||||||
assert s == "1.12345678"
|
|
||||||
|
|
||||||
|
|
||||||
def test_perc_parse():
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
assert parse_perc_amount("50%", ".") == Decimal(50)
|
|
||||||
assert parse_perc_amount("123.45", ".") == Decimal("123.45")
|
|
||||||
assert parse_perc_amount("abc", ".") is None
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All core input_rules tests passed")
|
|
||||||
@@ -8,15 +8,14 @@ Run:
|
|||||||
python3 tests/test_core_plugin_base.py
|
python3 tests/test_core_plugin_base.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
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 date, datetime, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
|
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
|
||||||
|
|
||||||
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# BalTimestamp
|
# BalTimestamp
|
||||||
@@ -207,7 +206,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"] == 20
|
assert settings["baltx_fees"] == 100
|
||||||
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
|
||||||
@@ -229,7 +228,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"] == 20
|
assert result["baltx_fees"] == 100
|
||||||
|
|
||||||
# normal settings unchanged
|
# normal settings unchanged
|
||||||
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}
|
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``bal.core.reminders`` (pure, GUI-free).
|
|
||||||
|
|
||||||
Covers the reminder-offset rules (BASIC + ADVANCED), the RFC-5545 helpers
|
|
||||||
(format_time, ical_escape, fold_ical_line), write_temp_ics, and the unified
|
|
||||||
``build_ics_reminders`` builder.
|
|
||||||
|
|
||||||
This module imports only ``bal.core``, so it runs in the lint venv (no
|
|
||||||
Electrum/Qt needed) as well as the runtime venv:
|
|
||||||
|
|
||||||
Run:
|
|
||||||
python3 tests/test_core_reminders.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
|
||||||
|
|
||||||
from bal.core.reminders import ( # noqa: E402 (path insert above)
|
|
||||||
BASIC_REMINDER_OFFSETS,
|
|
||||||
basic_reminder_offsets,
|
|
||||||
build_ics_reminders,
|
|
||||||
compute_reminder_offsets,
|
|
||||||
fold_ical_line,
|
|
||||||
format_time,
|
|
||||||
ical_escape,
|
|
||||||
write_temp_ics,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# compute_reminder_offsets - distribution rules
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_offsets_long_period_three_reminders():
|
|
||||||
"""30-day period, 3 reminders: spread out, all before the deadline."""
|
|
||||||
offsets = compute_reminder_offsets(30, 3)
|
|
||||||
assert len(offsets) == 3
|
|
||||||
# All strictly before the deadline (offset >= 1 means "n days before end").
|
|
||||||
assert all(o >= 1 for o in offsets)
|
|
||||||
# Sorted earliest-first (largest offset first).
|
|
||||||
assert offsets == sorted(offsets, reverse=True)
|
|
||||||
# One reminder near the start, one near the end.
|
|
||||||
assert max(offsets) == 30
|
|
||||||
assert min(offsets) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_offsets_capped_at_one_per_day():
|
|
||||||
"""Short period: at most one reminder per available day."""
|
|
||||||
# 2 days but 3 requested -> only 2 reminders, one per day.
|
|
||||||
assert compute_reminder_offsets(2, 3) == [2, 1]
|
|
||||||
# 1 day but 3 requested -> a single reminder, the day before the deadline.
|
|
||||||
assert compute_reminder_offsets(1, 3) == [1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_offsets_empty_when_no_room():
|
|
||||||
"""No reminders when there is no day before the deadline."""
|
|
||||||
assert compute_reminder_offsets(0, 3) == []
|
|
||||||
assert compute_reminder_offsets(-5, 3) == []
|
|
||||||
# A non-positive count also yields nothing.
|
|
||||||
assert compute_reminder_offsets(30, 0) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_offsets_never_exceed_requested_count():
|
|
||||||
"""The number of reminders never exceeds the requested count (max 5)."""
|
|
||||||
offsets = compute_reminder_offsets(100, 5)
|
|
||||||
assert len(offsets) == 5
|
|
||||||
assert all(o >= 1 for o in offsets)
|
|
||||||
# Distinct offsets only (no duplicate alarms on the same day).
|
|
||||||
assert len(set(offsets)) == len(offsets)
|
|
||||||
|
|
||||||
|
|
||||||
def test_offsets_single_reminder_is_day_before_deadline():
|
|
||||||
"""A single requested reminder fires one day before the deadline."""
|
|
||||||
assert compute_reminder_offsets(30, 1) == [1]
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# basic_reminder_offsets - fixed BASIC-mode offsets
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_full_period():
|
|
||||||
"""A far-away delivery keeps all three fixed offsets (30, 10, 1)."""
|
|
||||||
assert basic_reminder_offsets(365) == [30, 10, 1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_truncated_by_horizon():
|
|
||||||
"""Offsets beyond the delivery horizon are dropped."""
|
|
||||||
assert basic_reminder_offsets(20) == [10, 1]
|
|
||||||
assert basic_reminder_offsets(5) == [1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_empty():
|
|
||||||
"""No future offsets when the delivery is less than a day away."""
|
|
||||||
assert basic_reminder_offsets(0) == []
|
|
||||||
assert basic_reminder_offsets(-10) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_always_from_fixed_set():
|
|
||||||
"""Every returned offset is one of the fixed BASIC offsets."""
|
|
||||||
for horizon in range(0, 40):
|
|
||||||
result = basic_reminder_offsets(horizon)
|
|
||||||
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# format_time
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_time_utc():
|
|
||||||
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
|
|
||||||
assert format_time(dt) == "20250601T123045Z"
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_time_non_utc():
|
|
||||||
tz = timezone(timedelta(hours=2))
|
|
||||||
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=tz)
|
|
||||||
assert format_time(dt) == "20250601T103045Z"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# ical_escape
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_no_change():
|
|
||||||
assert ical_escape("hello world") == "hello world"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_backslash():
|
|
||||||
assert ical_escape("a\\b") == "a\\\\b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_semicolon():
|
|
||||||
assert ical_escape("a;b") == "a\\;b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_comma():
|
|
||||||
assert ical_escape("a,b") == "a\\,b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_multiline():
|
|
||||||
text = "line1\nline2"
|
|
||||||
result = ical_escape(text)
|
|
||||||
assert "line1" in result
|
|
||||||
assert "line2" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_all():
|
|
||||||
assert ical_escape("\\;,") == "\\\\\\;\\,"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# fold_ical_line
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_fold_ical_line_short():
|
|
||||||
assert fold_ical_line("SUMMARY:Test") == "SUMMARY:Test"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fold_ical_line_long():
|
|
||||||
line = "SUMMARY:" + "a" * 100
|
|
||||||
result = fold_ical_line(line, limit=75)
|
|
||||||
parts = result.split("\r\n ")
|
|
||||||
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
|
|
||||||
assert "".join(parts) == line
|
|
||||||
|
|
||||||
|
|
||||||
def test_fold_ical_line_unicode():
|
|
||||||
line = "SUMMARY:" + "é" * 50
|
|
||||||
result = fold_ical_line(line, limit=75)
|
|
||||||
parts = result.split("\r\n ")
|
|
||||||
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
|
|
||||||
assert "".join(parts) == line
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# write_temp_ics
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_temp_ics():
|
|
||||||
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
|
||||||
path = write_temp_ics(content)
|
|
||||||
try:
|
|
||||||
assert os.path.isfile(path)
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
assert f.read() == content.encode("utf-8")
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_temp_ics_empty():
|
|
||||||
path = write_temp_ics("")
|
|
||||||
try:
|
|
||||||
assert os.path.isfile(path)
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
assert f.read() == b""
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# build_ics_reminders - unified builder
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
_LOCKTIME = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _common_kwargs():
|
|
||||||
return dict(
|
|
||||||
locktime=_LOCKTIME,
|
|
||||||
description="BAL will of $wallet_name\r\n$heirs_complete",
|
|
||||||
summary="BAL - Will execution of $wallet_name",
|
|
||||||
wallet_name="karen7",
|
|
||||||
heirs_details=" alice - bc1qalice, bob - bc1qbob",
|
|
||||||
version="0.6.1",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_ics_advanced_separate_events():
|
|
||||||
"""ADVANCED: 30-day period -> offsets [30, 16, 1], one VEVENT each."""
|
|
||||||
content = build_ics_reminders(
|
|
||||||
basic_mode=False,
|
|
||||||
num_reminders=3,
|
|
||||||
now=datetime(2026, 7, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
||||||
threshold=datetime(2026, 6, 23, 0, 0, 0, tzinfo=timezone.utc),
|
|
||||||
**_common_kwargs(),
|
|
||||||
)
|
|
||||||
assert content is not None
|
|
||||||
assert content.startswith("BEGIN:VCALENDAR")
|
|
||||||
assert content.endswith("\r\n")
|
|
||||||
lines = content.split("\r\n")
|
|
||||||
assert lines.count("BEGIN:VEVENT") == 3
|
|
||||||
assert lines.count("END:VEVENT") == 3
|
|
||||||
assert "BEGIN:VALARM" not in lines
|
|
||||||
# Unique UIDs, numbered summaries, last event one day before the deadline.
|
|
||||||
uids = [ln for ln in lines if ln.startswith("UID:")]
|
|
||||||
assert len(uids) == len(set(uids)) == 3
|
|
||||||
assert any("(reminder 1/3)" in ln for ln in lines)
|
|
||||||
assert any("(reminder 3/3)" in ln for ln in lines)
|
|
||||||
last_dt = format_time(_LOCKTIME - timedelta(days=1))
|
|
||||||
assert f"DTSTART:{last_dt}" in lines
|
|
||||||
# Template substitution; the CRLF inside the description stays as a line
|
|
||||||
# break in the folded DESCRIPTION (no literal "\n" escaping in ical_escape).
|
|
||||||
desc = [ln for ln in lines if ln.startswith("DESCRIPTION:")]
|
|
||||||
assert desc and "karen7" in desc[0]
|
|
||||||
assert any(ln.lstrip().startswith("alice") for ln in lines)
|
|
||||||
|
|
||||||
def test_build_ics_basic_uses_fixed_offsets():
|
|
||||||
"""BASIC: fixed offsets (30, 10, 1) filtered to the future, threshold not
|
|
||||||
required."""
|
|
||||||
content = build_ics_reminders(
|
|
||||||
basic_mode=True,
|
|
||||||
now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
||||||
**_common_kwargs(),
|
|
||||||
)
|
|
||||||
assert content is not None
|
|
||||||
lines = content.split("\r\n")
|
|
||||||
uids = [ln for ln in lines if ln.startswith("UID:")]
|
|
||||||
assert len(uids) == 3
|
|
||||||
# The first event is the 30-day offset.
|
|
||||||
assert "bal-karen7-30d" in uids[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_ics_returns_none_when_no_reminders():
|
|
||||||
"""No future reminder -> None (the aligned behavior), not an empty file."""
|
|
||||||
content = build_ics_reminders(
|
|
||||||
basic_mode=True,
|
|
||||||
now=datetime(2026, 7, 23, 0, 0, 0, tzinfo=timezone.utc),
|
|
||||||
**_common_kwargs(),
|
|
||||||
)
|
|
||||||
assert content is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_ics_advanced_requires_threshold():
|
|
||||||
"""ADVANCED mode without a threshold is a programming error."""
|
|
||||||
try:
|
|
||||||
build_ics_reminders(basic_mode=False, **_common_kwargs())
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
raise AssertionError("expected ValueError for missing threshold in ADVANCED")
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All core reminders tests passed")
|
|
||||||
@@ -9,14 +9,13 @@ Run:
|
|||||||
python3 tests/test_core_util.py
|
python3 tests/test_core_util.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
import pytest # pyright: ignore[reportMissingImports]
|
import pytest
|
||||||
|
|
||||||
from bal.core.util import Util
|
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_to_str():
|
def test_locktime_to_str():
|
||||||
@@ -41,7 +40,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(ValueError):
|
with pytest.raises(Exception):
|
||||||
Util.str_to_locktime("144b")
|
Util.str_to_locktime("144b")
|
||||||
|
|
||||||
# integer string -> int
|
# integer string -> int
|
||||||
@@ -85,63 +84,6 @@ def test_int_locktime():
|
|||||||
assert Util.int_locktime() == 0
|
assert Util.int_locktime() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_relative_days():
|
|
||||||
assert Util._relative_days("30d") == 30
|
|
||||||
assert Util._relative_days("1y") == 365
|
|
||||||
assert Util._relative_days("2y") == 730
|
|
||||||
assert Util._relative_days("30D") == 30
|
|
||||||
assert Util._relative_days(1700000000) is None
|
|
||||||
assert Util._relative_days("1700000000") is None
|
|
||||||
assert Util._relative_days("garbage") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_locktime_against_tx_absolute():
|
|
||||||
"""An absolute current date is returned unchanged (compared vs the tx)."""
|
|
||||||
frozen = 1817438400
|
|
||||||
assert Util.resolve_locktime_against_tx(str(frozen), "1y", frozen) == frozen
|
|
||||||
assert Util.resolve_locktime_against_tx(frozen, str(frozen), frozen) == frozen
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_locktime_against_tx_unchanged_relative():
|
|
||||||
"""An unchanged relative recipe resolves to exactly the frozen tx locktime
|
|
||||||
(coherent), instead of drifting one day per day away from it."""
|
|
||||||
frozen = 1817438400 # 2027-08-05, i.e. a tx built 2026-08-05 with "1y"
|
|
||||||
resolved = Util.resolve_locktime_against_tx("1y", "1y", frozen)
|
|
||||||
assert resolved == frozen
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_locktime_against_tx_lengthened():
|
|
||||||
"""A lengthened relative recipe resolves later than the frozen tx locktime
|
|
||||||
(this is what the postpone check uses to trigger invalidation)."""
|
|
||||||
frozen = 1817438400 # tx built 2026-08-05 with "1y" -> delivery 2027-08-05
|
|
||||||
resolved = Util.resolve_locktime_against_tx("2y", "1y", frozen)
|
|
||||||
assert resolved == frozen + 365 * 86400
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_locktime_against_tx_shortened():
|
|
||||||
"""A shortened relative recipe resolves earlier than the frozen tx locktime
|
|
||||||
(this is what the anticipate/rebuild path uses)."""
|
|
||||||
frozen = 1817438400
|
|
||||||
resolved = Util.resolve_locktime_against_tx("30d", "1y", frozen)
|
|
||||||
assert resolved < frozen
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_locktime_against_tx_no_relative_anchor():
|
|
||||||
"""When the built recipe was absolute there is no anchor: falls back to the
|
|
||||||
legacy forward-from-now resolution (returns a timestamp, no crash)."""
|
|
||||||
frozen = 1817438400
|
|
||||||
result = Util.resolve_locktime_against_tx("30d", str(frozen), frozen)
|
|
||||||
assert isinstance(result, int)
|
|
||||||
assert result > 1700000000
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_locktime_against_tx_zero_tx_locktime():
|
|
||||||
"""A tx with no usable locktime falls back to now-based resolution."""
|
|
||||||
result = Util.resolve_locktime_against_tx("1y", "1y", 0)
|
|
||||||
assert isinstance(result, int)
|
|
||||||
assert result > 1700000000
|
|
||||||
|
|
||||||
|
|
||||||
def test_encode_decode_amount():
|
def test_encode_decode_amount():
|
||||||
dp = 8 # typical BTC decimal point
|
dp = 8 # typical BTC decimal point
|
||||||
|
|
||||||
@@ -405,44 +347,44 @@ def test_in_utxo():
|
|||||||
|
|
||||||
|
|
||||||
def test_cmp_output():
|
def test_cmp_output():
|
||||||
class Obj:
|
class O:
|
||||||
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(Obj("a", 100), Obj("a", 100)) is True
|
assert Util.cmp_output(O("a", 100), O("a", 100)) is True
|
||||||
assert Util.cmp_output(Obj("a", 100), Obj("b", 100)) is False
|
assert Util.cmp_output(O("a", 100), O("b", 100)) is False
|
||||||
assert Util.cmp_output(Obj("a", 100), Obj("a", 200)) is False
|
assert Util.cmp_output(O("a", 100), O("a", 200)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_in_output():
|
def test_in_output():
|
||||||
class Obj:
|
class O:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
outputs = [Obj("a", 100), Obj("b", 200)]
|
outputs = [O("a", 100), O("b", 200)]
|
||||||
assert Util.in_output(Obj("a", 100), outputs) is True
|
assert Util.in_output(O("a", 100), outputs) is True
|
||||||
assert Util.in_output(Obj("z", 999), outputs) is False
|
assert Util.in_output(O("z", 999), outputs) is False
|
||||||
assert Util.in_output(Obj("a", 100), []) is False
|
assert Util.in_output(O("a", 100), []) is False
|
||||||
|
|
||||||
|
|
||||||
def test_din_output():
|
def test_din_output():
|
||||||
class Obj:
|
class O:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
|
|
||||||
outputs = [Obj("a", 100), Obj("b", 200)]
|
outputs = [O("a", 100), O("b", 200)]
|
||||||
|
|
||||||
# same amount AND same address
|
# same amount AND same address
|
||||||
same_amt, same_addr = Util.din_output(Obj("a", 100), outputs)
|
same_amt, same_addr = Util.din_output(O("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(Obj("c", 100), outputs)
|
same_amt, same_addr = Util.din_output(O("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(Obj("z", 999), outputs)
|
same_amt, same_addr = Util.din_output(O("z", 999), outputs)
|
||||||
assert same_amt is False and same_addr is False
|
assert same_amt is False and same_addr is False
|
||||||
|
|
||||||
|
|
||||||
@@ -497,13 +439,6 @@ if __name__ == "__main__":
|
|||||||
test_str_to_locktime()
|
test_str_to_locktime()
|
||||||
test_parse_locktime_string()
|
test_parse_locktime_string()
|
||||||
test_int_locktime()
|
test_int_locktime()
|
||||||
test_relative_days()
|
|
||||||
test_resolve_locktime_against_tx_absolute()
|
|
||||||
test_resolve_locktime_against_tx_unchanged_relative()
|
|
||||||
test_resolve_locktime_against_tx_lengthened()
|
|
||||||
test_resolve_locktime_against_tx_shortened()
|
|
||||||
test_resolve_locktime_against_tx_no_relative_anchor()
|
|
||||||
test_resolve_locktime_against_tx_zero_tx_locktime()
|
|
||||||
test_encode_decode_amount()
|
test_encode_decode_amount()
|
||||||
test_is_perc()
|
test_is_perc()
|
||||||
test_cmp_array()
|
test_cmp_array()
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ Run:
|
|||||||
python3 tests/test_core_will.py
|
python3 tests/test_core_will.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import copy
|
||||||
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, WillItem
|
from bal.core.will import WillItem, Will
|
||||||
|
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,19 +332,11 @@ def test_will_check_tx_height():
|
|||||||
|
|
||||||
def test_exceptions():
|
def test_exceptions():
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
AmountException,
|
WillException, WillExpiredException, NotCompleteWillException,
|
||||||
FixedAmountException,
|
HeirChangeException, TxFeesChangedException, HeirNotFoundException,
|
||||||
HeirChangeException,
|
WillexecutorChangeException, NoWillExecutorNotPresent,
|
||||||
HeirNotFoundException,
|
WillExecutorNotPresent, NoHeirsException,
|
||||||
NoHeirsException,
|
AmountException, PercAmountException, FixedAmountException,
|
||||||
NotCompleteWillException,
|
|
||||||
NoWillExecutorNotPresent,
|
|
||||||
PercAmountException,
|
|
||||||
TxFeesChangedException,
|
|
||||||
WillException,
|
|
||||||
WillexecutorChangeException,
|
|
||||||
WillExecutorNotPresent,
|
|
||||||
WillExpiredException,
|
|
||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -383,4 +375,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("[OK] All Will tests passed")
|
print(f"[OK] All Will tests passed")
|
||||||
|
|||||||
@@ -8,28 +8,14 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from binascii import unhexlify
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch, PropertyMock, call
|
||||||
|
|
||||||
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"
|
||||||
@@ -184,8 +170,6 @@ 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})
|
||||||
@@ -201,9 +185,6 @@ 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})
|
||||||
@@ -215,141 +196,6 @@ 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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -371,472 +217,6 @@ 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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -1,483 +0,0 @@
|
|||||||
"""
|
|
||||||
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,6 +28,7 @@ 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,6 +27,7 @@ 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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -64,47 +65,6 @@ 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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -123,10 +83,9 @@ 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 nine settings: the original six, the Group C
|
The dialog exposes seven settings: the original six plus the Group C
|
||||||
"Editable dates" checkbox, and the Group H "Save inheritance transactions
|
"Editable dates" checkbox, which the Reset button must also restore (this
|
||||||
in history" checkbox + "History label" field, which the Reset button must
|
was a follow-up fix after the first test round).
|
||||||
also restore.
|
|
||||||
"""
|
"""
|
||||||
cfg = FakeConfig()
|
cfg = FakeConfig()
|
||||||
|
|
||||||
@@ -144,10 +103,6 @@ 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,
|
||||||
@@ -156,8 +111,6 @@ 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.
|
||||||
@@ -168,15 +121,11 @@ 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)
|
||||||
@@ -184,11 +133,6 @@ 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():
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ Covered behaviour:
|
|||||||
most one reminder per available day, and never returns more reminders than
|
most one reminder per available day, and never returns more reminders than
|
||||||
requested.
|
requested.
|
||||||
|
|
||||||
``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
|
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
||||||
so these tests run without Qt (or Electrum) at all.
|
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
||||||
|
the other GUI tests.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
python3 tests/test_group_d_alarms.py
|
python3 -m pytest tests/test_group_d_alarms.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -24,7 +25,8 @@ import sys
|
|||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
from bal.core.reminders import compute_reminder_offsets
|
from bal.gui.qt.widgets import compute_reminder_offsets
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
|
|||||||
@@ -1,397 +0,0 @@
|
|||||||
"""
|
|
||||||
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,21 +30,18 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest # pyright: ignore[reportMissingImports]
|
import pytest
|
||||||
|
|
||||||
# Make the plugin package importable when run directly (tests/ is one level
|
# Make the plugin package importable when run directly (tests/ is one level
|
||||||
# below the repo root that contains the ``bal`` package).
|
# below the repo root that contains the ``bal`` package).
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.will import WillItem, Will, HeirNotFoundException
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
from bal.core.reminders import (
|
|
||||||
compute_reminder_offsets,
|
|
||||||
format_time,
|
|
||||||
ical_escape,
|
|
||||||
write_temp_ics,
|
|
||||||
)
|
|
||||||
from bal.core.will import HeirNotFoundException, Will, WillItem
|
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
from bal.gui.qt.calendar import BalCalendar
|
||||||
|
from bal.gui.qt.widgets import compute_reminder_offsets
|
||||||
|
|
||||||
|
|
||||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||||
# version 2). Reused from test_core_will.py so WillItem can parse a real tx.
|
# version 2). Reused from test_core_will.py so WillItem can parse a real tx.
|
||||||
@@ -200,8 +197,8 @@ def test_e1_build_separate_events_for_karen7():
|
|||||||
|
|
||||||
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
||||||
for idx, offset in enumerate(offsets, start=1):
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
event_dt = format_time(locktime - timedelta(days=offset))
|
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||||||
summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
f"UID:bal-{wallet}-{offset}d",
|
f"UID:bal-{wallet}-{offset}d",
|
||||||
@@ -223,7 +220,7 @@ def test_e1_build_separate_events_for_karen7():
|
|||||||
assert any("(reminder 1/3)" in ln for ln in lines)
|
assert any("(reminder 1/3)" in ln for ln in lines)
|
||||||
assert any("(reminder 3/3)" in ln for ln in lines)
|
assert any("(reminder 3/3)" in ln for ln in lines)
|
||||||
# The LAST event (offset 1) sits one day before the locktime.
|
# The LAST event (offset 1) sits one day before the locktime.
|
||||||
last_dt = format_time(locktime - timedelta(days=1))
|
last_dt = BalCalendar.format_time(locktime - timedelta(days=1))
|
||||||
assert f"DTSTART:{last_dt}" in lines
|
assert f"DTSTART:{last_dt}" in lines
|
||||||
|
|
||||||
|
|
||||||
@@ -231,7 +228,7 @@ def test_e1_event_description_escaping():
|
|||||||
"""Special iCalendar characters in karen7's event text are escaped so
|
"""Special iCalendar characters in karen7's event text are escaped so
|
||||||
the .ics file stays valid."""
|
the .ics file stays valid."""
|
||||||
raw = "Wallet karen7; heirs: alice, bob"
|
raw = "Wallet karen7; heirs: alice, bob"
|
||||||
escaped = ical_escape(raw)
|
escaped = BalCalendar.ical_escape(raw)
|
||||||
assert "\\;" in escaped # semicolon escaped
|
assert "\\;" in escaped # semicolon escaped
|
||||||
assert "\\," in escaped # comma escaped
|
assert "\\," in escaped # comma escaped
|
||||||
assert "karen7" in escaped
|
assert "karen7" in escaped
|
||||||
@@ -249,7 +246,7 @@ def test_e1_write_temp_ics_for_karen7():
|
|||||||
"END:VEVENT\r\n"
|
"END:VEVENT\r\n"
|
||||||
"END:VCALENDAR\r\n"
|
"END:VCALENDAR\r\n"
|
||||||
)
|
)
|
||||||
path = write_temp_ics(content)
|
path = BalCalendar.write_temp_ics(content)
|
||||||
try:
|
try:
|
||||||
assert os.path.isfile(path)
|
assert os.path.isfile(path)
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
@@ -278,7 +275,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") or {})
|
assert "charlie" in wallet.db.get("heirs", {})
|
||||||
|
|
||||||
removed = heirs.pop("alice")
|
removed = heirs.pop("alice")
|
||||||
assert removed is not None
|
assert removed is not None
|
||||||
@@ -553,7 +550,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 _, out_map in outputs.items():
|
for addr, 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():
|
||||||
@@ -626,7 +623,6 @@ 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,9 +16,8 @@ Run:
|
|||||||
python3 tests/test_group_f_heir_change_rebuild.py
|
python3 tests/test_group_f_heir_change_rebuild.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import Will
|
from bal.core.will import Will
|
||||||
@@ -113,5 +112,5 @@ def test_same_heirs_empty():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import pytest # pyright: ignore[reportMissingImports]
|
import pytest
|
||||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ that would fall in the past.
|
|||||||
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
||||||
drives that decision.
|
drives that decision.
|
||||||
|
|
||||||
``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
|
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
||||||
so these tests run without Qt (or Electrum) at all.
|
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
||||||
|
the other GUI tests.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
python3 tests/test_group_g_basic_calendar.py
|
python3 -m pytest tests/test_group_g_basic_calendar.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -23,7 +24,8 @@ 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.reminders import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
|
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
|
||||||
|
basic_reminder_offsets)
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_all_future():
|
def test_basic_offsets_all_future():
|
||||||
|
|||||||
@@ -3,99 +3,117 @@ Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
|
|||||||
|
|
||||||
Background (reported by the owner): with the plugin left in BASIC mode (the
|
Background (reported by the owner): with the plugin left in BASIC mode (the
|
||||||
default), the "Check Alive" (threshold) field is hidden from the user and
|
default), the "Check Alive" (threshold) field is hidden from the user and
|
||||||
stays stuck at its old/default value. If the user then anticipates the
|
stays stuck at its old/default value (roughly "today + 11 months", i.e. one
|
||||||
delivery time (locktime) to something earlier than that stale threshold, the
|
year minus the default 30-day margin). If the user then anticipates the
|
||||||
checks that compare locktime against ``date_to_check`` would incorrectly treat
|
delivery time (locktime) to something earlier than that stale threshold - e.g.
|
||||||
the will as "expired"/"invalid", even though the whole Check Alive concept is
|
"in 1 month" - two different checks that compare locktime against
|
||||||
supposed to be inert in BASIC mode.
|
``date_to_check`` (the resolved threshold) would incorrectly treat the will as
|
||||||
|
"expired"/"invalid", even though the whole Check Alive concept is supposed to
|
||||||
|
be inert in BASIC mode.
|
||||||
|
|
||||||
The real fix lives in ``BalWindow.init_class_variables`` (window.py), which
|
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track
|
||||||
sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
|
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it,
|
||||||
pure policy in ``bal.core.checkalive``:
|
so it is always < locktime by construction. In ADVANCED mode nothing changes:
|
||||||
|
the stored threshold is used as-is.
|
||||||
|
|
||||||
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
|
These tests call the real production method directly (no GUI/Electrum wallet
|
||||||
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
|
needed - it is a plain ``@staticmethod``), so they exercise the exact code
|
||||||
* ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
|
used at runtime rather than a re-implementation.
|
||||||
Check-Alive guard should fire (never in BASIC).
|
|
||||||
|
|
||||||
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
|
|
||||||
needed, and no Qt import).
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q
|
||||||
python3 tests/test_group_i_basic_checkalive.py
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
|
||||||
CheckAliveError,
|
|
||||||
check_alive_expired,
|
|
||||||
resolve_date_to_check,
|
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_is_two_hours():
|
||||||
|
"""Owner-approved margin: exactly 2 hours."""
|
||||||
|
assert OFFSET == 2 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _relative_days_to_midnight_timestamp(days):
|
||||||
|
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N
|
||||||
|
days", truncated to midnight. Used to compute the expected value in
|
||||||
|
tests without duplicating the parsing logic itself."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
return (
|
||||||
|
(now + timedelta(days=days))
|
||||||
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
.timestamp()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_resolves_to_now():
|
def test_basic_mode_relative_locktime_one_year():
|
||||||
"""BASIC mode: date_to_check is "now", never the stale stored threshold."""
|
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier."""
|
||||||
fake_now = 1_800_000_000.0
|
result = BalWindow.compute_date_to_check(True, "1y", "30d")
|
||||||
result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
|
expected_locktime = _relative_days_to_midnight_timestamp(365)
|
||||||
assert result == fake_now
|
assert abs(result - (expected_locktime - OFFSET)) < 5
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_ignores_stored_threshold():
|
def test_basic_mode_anticipated_delivery_stays_consistent():
|
||||||
"""BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
|
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
|
||||||
does NOT jump to the old ~11-month threshold - this is the exact bug
|
NOT the old ~11-month threshold - this is the exact bug scenario
|
||||||
scenario reported by the owner."""
|
reported by the owner."""
|
||||||
stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
|
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
|
||||||
fake_now = 1_800_000_000.0
|
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
|
||||||
result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
|
result = BalWindow.compute_date_to_check(
|
||||||
assert result == fake_now
|
True, anticipated_locktime, stale_threshold
|
||||||
|
)
|
||||||
|
locktime_ts = _relative_days_to_midnight_timestamp(30)
|
||||||
|
# date_to_check must be (delivery - 2h), always strictly before delivery.
|
||||||
|
assert result < locktime_ts
|
||||||
|
assert abs((locktime_ts - OFFSET) - result) < 5
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_uses_stored_threshold():
|
def test_basic_mode_date_to_check_always_before_locktime():
|
||||||
"""ADVANCED mode: behaviour stays exactly as before - the stored threshold
|
"""Regression guard for the reported bug: whatever the delivery date is
|
||||||
is used as-is, regardless of the locktime value."""
|
(even very close to "now"), date_to_check must stay before it."""
|
||||||
|
for relative_locktime in ("1d", "7d", "30d", "90d", "365d"):
|
||||||
|
result = BalWindow.compute_date_to_check(True, relative_locktime, "30d")
|
||||||
|
parsed_locktime = _relative_days_to_midnight_timestamp(
|
||||||
|
int(relative_locktime[:-1])
|
||||||
|
)
|
||||||
|
assert result < parsed_locktime, (
|
||||||
|
f"date_to_check ({result}) should be before locktime "
|
||||||
|
f"({parsed_locktime}) for locktime={relative_locktime}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_uses_stored_threshold_unchanged():
|
||||||
|
"""ADVANCED mode: behaviour must stay exactly as before this fix - the
|
||||||
|
stored threshold is used as-is, regardless of the locktime value."""
|
||||||
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
|
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
|
||||||
result = resolve_date_to_check(False, {"threshold": absolute_threshold})
|
result = BalWindow.compute_date_to_check(False, "30d", absolute_threshold)
|
||||||
assert abs(result - absolute_threshold) < 1
|
assert abs(result - absolute_threshold) < 1
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_parses_relative_threshold():
|
def test_basic_mode_falls_back_on_unparsable_locktime():
|
||||||
"""ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
|
"""If the locktime can't be parsed for any reason, BASIC mode must not
|
||||||
result = resolve_date_to_check(False, {"threshold": "30d"})
|
crash: it falls back to the stored threshold, same as ADVANCED."""
|
||||||
assert result > time.time()
|
absolute_threshold = time.time() + 5 * 86400
|
||||||
|
result = BalWindow.compute_date_to_check(
|
||||||
|
True, {"not": "a valid locktime"}, absolute_threshold
|
||||||
def test_basic_mode_never_expired():
|
)
|
||||||
"""BASIC mode can never be "expired": a passed check-alive date must never
|
assert abs(result - absolute_threshold) < 1
|
||||||
force a postpone/rewrite of the will."""
|
|
||||||
assert check_alive_expired(True, time.time() - 10_000) is False
|
|
||||||
assert check_alive_expired(True, 1_000_000_000.0) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_expired_when_past():
|
|
||||||
assert check_alive_expired(False, time.time() - 10_000) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_mode_not_expired_when_future():
|
|
||||||
assert check_alive_expired(False, time.time() + 10_000) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_never_raises_check_alive_error():
|
|
||||||
"""Regression guard for the reported bug: whatever the delivery date,
|
|
||||||
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
|
|
||||||
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
|
|
||||||
assert not check_alive_expired(True, date_to_check)
|
|
||||||
# If it did fire, this is the exact exception that would be raised.
|
|
||||||
assert issubclass(CheckAliveError, Exception)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for name in sorted(dir()):
|
test_offset_is_two_hours()
|
||||||
if name.startswith("test_"):
|
test_basic_mode_relative_locktime_one_year()
|
||||||
globals()[name]()
|
test_basic_mode_anticipated_delivery_stays_consistent()
|
||||||
print(f" [OK] {name}")
|
test_basic_mode_date_to_check_always_before_locktime()
|
||||||
print("[OK] All test_group_i_basic_checkalive tests passed.")
|
test_advanced_mode_uses_stored_threshold_unchanged()
|
||||||
|
test_basic_mode_falls_back_on_unparsable_locktime()
|
||||||
|
print("All test_group_i_basic_checkalive tests passed.")
|
||||||
|
|||||||
@@ -1,25 +1,128 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.gui.qt.calendar``.
|
Tests for ``bal.gui.qt.calendar``.
|
||||||
|
|
||||||
Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
|
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
|
||||||
The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
|
write_temp_ics, open_with_default_app.
|
||||||
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.calendar import BalCalendar
|
from bal.gui.qt.calendar import BalCalendar
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# format_time
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_format_time_utc():
|
||||||
|
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
|
||||||
|
assert BalCalendar.format_time(dt) == "20250601T123045Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_non_utc():
|
||||||
|
from datetime import timedelta
|
||||||
|
tz = timezone(timedelta(hours=2))
|
||||||
|
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
|
||||||
|
result = BalCalendar.format_time(dt)
|
||||||
|
assert result.endswith("Z")
|
||||||
|
assert result == "20250115T060000Z"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# ical_escape
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_ical_escape_no_change():
|
||||||
|
text = "hello world"
|
||||||
|
assert BalCalendar.ical_escape(text) == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_backslash():
|
||||||
|
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_semicolon():
|
||||||
|
assert BalCalendar.ical_escape("a;b") == "a\\;b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_comma():
|
||||||
|
assert BalCalendar.ical_escape("a,b") == "a\\,b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_multiline():
|
||||||
|
text = "line1\r\nline2"
|
||||||
|
result = BalCalendar.ical_escape(text)
|
||||||
|
assert "\r\n" in result
|
||||||
|
assert "line1" in result
|
||||||
|
assert "line2" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_all():
|
||||||
|
text = "\\;,"
|
||||||
|
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# fold_ical_line
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_fold_ical_line_short():
|
||||||
|
line = "SUMMARY:Test"
|
||||||
|
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_ical_line_long():
|
||||||
|
line = "X-LONG:" + "a" * 100
|
||||||
|
result = BalCalendar.fold_ical_line(line, limit=75)
|
||||||
|
parts = result.split("\r\n ")
|
||||||
|
assert len(parts) > 1
|
||||||
|
assert result.startswith("X-LONG:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_ical_line_unicode():
|
||||||
|
line = "DESCRIPTION:" + "\u20ac" * 40
|
||||||
|
result = BalCalendar.fold_ical_line(line, limit=75)
|
||||||
|
assert "\r\n " in result
|
||||||
|
assert "\u20ac" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# write_temp_ics
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_write_temp_ics():
|
||||||
|
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
||||||
|
path = BalCalendar.write_temp_ics(content)
|
||||||
|
try:
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
assert f.read() == content.encode("utf-8")
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_temp_ics_empty():
|
||||||
|
path = BalCalendar.write_temp_ics("")
|
||||||
|
try:
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
assert f.read() == b""
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# open_with_default_app
|
# open_with_default_app
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
def test_open_with_default_app_not_found():
|
def test_open_with_default_app_not_found():
|
||||||
result = BalCalendar.open_with_default_app(
|
result = BalCalendar.open_with_default_app(
|
||||||
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.gui.qt.common``.
|
Tests for ``bal.gui.qt.common``.
|
||||||
|
|
||||||
Covers shown_cv, add_widget, log_error, export_meta_gui (and the
|
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui.
|
||||||
CheckAliveError exception, which now lives in ``bal.core.checkalive``).
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel
|
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
|
||||||
|
|
||||||
# Import the module itself, not via "from .common import *"
|
# Import the module itself, not via "from .common import *"
|
||||||
import bal.gui.qt.common as common
|
import bal.gui.qt.common as C
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
@@ -25,18 +23,18 @@ _app = QApplication.instance() or QApplication(sys.argv)
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_shown_cv_default():
|
def test_shown_cv_default():
|
||||||
cv = common.shown_cv(True)
|
cv = C.shown_cv(True)
|
||||||
assert cv.get() is True
|
assert cv.get() is True
|
||||||
|
|
||||||
|
|
||||||
def test_shown_cv_set():
|
def test_shown_cv_set():
|
||||||
cv = common.shown_cv(True)
|
cv = C.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 = common.shown_cv(False)
|
cv = C.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
|
||||||
@@ -45,30 +43,23 @@ def test_shown_cv_roundtrip():
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# CheckAliveError (moved to bal.core.checkalive; kept tested via the
|
# CheckAliveError
|
||||||
# common->core import chain)
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
def test_check_alive_error_default():
|
||||||
from bal.core.checkalive import CheckAliveError
|
err = C.CheckAliveError(1000000)
|
||||||
|
|
||||||
err = CheckAliveError(1000000)
|
|
||||||
assert err.timestamp_to_check == 1000000
|
assert err.timestamp_to_check == 1000000
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
def test_check_alive_error_str():
|
||||||
from bal.core.checkalive import CheckAliveError
|
err = C.CheckAliveError(1000000)
|
||||||
|
|
||||||
err = CheckAliveError(1000000)
|
|
||||||
s = str(err)
|
s = str(err)
|
||||||
assert "Check alive expired" in s
|
assert "Check alive expired" in s
|
||||||
assert "1970" in s
|
assert "1970" in s
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
def test_check_alive_error_subclass():
|
||||||
from bal.core.checkalive import CheckAliveError
|
assert issubclass(C.CheckAliveError, Exception)
|
||||||
|
|
||||||
assert issubclass(CheckAliveError, Exception)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -77,15 +68,17 @@ 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")
|
||||||
common.add_widget(grid, "Label", label, 0, "Help text")
|
C.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()
|
||||||
common.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
parent = QWidget()
|
||||||
common.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
C.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
||||||
|
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
||||||
assert grid.count() == 6
|
assert grid.count() == 6
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +87,7 @@ def test_add_widget_multiple_rows():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_log_error_no_window():
|
def test_log_error_no_window():
|
||||||
common.log_error((Exception, Exception("test"), None))
|
C.log_error((Exception, Exception("test"), None))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
"""
|
|
||||||
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,19 +8,16 @@ 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 signature_suffix, status_color
|
from bal.gui.qt.theme import 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):
|
||||||
@@ -100,54 +97,6 @@ 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,11 +13,13 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,25 +120,23 @@ def test_locktime_editor_min_max():
|
|||||||
def test_locktime_raw_edit_replace_str():
|
def test_locktime_raw_edit_replace_str():
|
||||||
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
||||||
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
||||||
# anymore (locktimes are always UNIX timestamps now). The helper moved to
|
# anymore (locktimes are always UNIX timestamps now).
|
||||||
# bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
|
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||||
from bal.core.input_rules import replace_dy_suffixes
|
assert LockTimeRawEdit.replace_str("123d") == "123"
|
||||||
assert replace_dy_suffixes("123d") == "123"
|
assert LockTimeRawEdit.replace_str("456y") == "456"
|
||||||
assert replace_dy_suffixes("456y") == "456"
|
|
||||||
# "b" is left untouched (no longer a recognised suffix)
|
# "b" is left untouched (no longer a recognised suffix)
|
||||||
assert replace_dy_suffixes("789b") == "789b"
|
assert LockTimeRawEdit.replace_str("789b") == "789b"
|
||||||
# only d/y are stripped; a stray "b" remains
|
# only d/y are stripped; a stray "b" remains
|
||||||
assert replace_dy_suffixes("12d34y56b") == "123456b"
|
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b"
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_raw_edit_checkbdy():
|
def test_locktime_raw_edit_checkbdy():
|
||||||
# checkbdy moved to bal.core.input_rules (_checkbdy).
|
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||||
from bal.core.input_rules import _checkbdy
|
|
||||||
# character at expected position matches appendix
|
# character at expected position matches appendix
|
||||||
pos, s = _checkbdy("123d", 4, "d")
|
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d")
|
||||||
assert s == "123d"
|
assert s == "123d"
|
||||||
# character at expected position does not match
|
# character at expected position does not match
|
||||||
pos, s = _checkbdy("123x", 4, "d")
|
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d")
|
||||||
assert s == "123x"
|
assert s == "123x"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
"""
|
|
||||||
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,18 +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.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,
|
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of,
|
||||||
show_modal,
|
|
||||||
show_on_top,
|
|
||||||
stop_thread,
|
|
||||||
top_level_of,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the relative-recipe anchoring in the will coherence check.
|
|
||||||
|
|
||||||
Regression for the reported bug: a wallet built with RELATIVE locktimes
|
|
||||||
(``"1y"`` on the heirs, relative will_settings) was asked to invalidate the
|
|
||||||
will EVERY DAY. The relative recipes were re-parsed against *now* on every
|
|
||||||
check, so they drifted one day per day away from the fixed locktime frozen
|
|
||||||
inside the signed transaction and the check mistook the (unchanged) will for a
|
|
||||||
POSTPONE / EXPIRED one.
|
|
||||||
|
|
||||||
The two gates that produced the prompt are covered here:
|
|
||||||
|
|
||||||
1. ``Will.check_willexecutors_and_heirs`` must treat an UNCHANGED relative
|
|
||||||
recipe as coherent (resolved against the build moment, not "now"), while
|
|
||||||
still detecting a genuinely lengthened recipe as a postpone.
|
|
||||||
2. ``resolve_date_to_check`` (ADVANCED mode) must anchor a relative stored
|
|
||||||
locktime to the built transactions' frozen delivery date, so the will is
|
|
||||||
never read as EXPIRED because the check window drifts past the frozen
|
|
||||||
tx locktime.
|
|
||||||
|
|
||||||
The karen7 regtest wallet fixture (``tests/karen7``) reproduces the exact
|
|
||||||
reported state: heirs with ``"1y"``, a signed/pushed/checked item whose frozen
|
|
||||||
tx.locktime is 2027-08-05 (built 2026-08-05), and will_settings
|
|
||||||
``{"locktime": "2y", "threshold": "150d"}``.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
|
||||||
python3 tests/test_heir_relative_anchor.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|
||||||
|
|
||||||
from electrum import constants # noqa: E402 (path insert above)
|
|
||||||
|
|
||||||
constants.net = constants.BitcoinRegtest
|
|
||||||
|
|
||||||
from bal.core.checkalive import resolve_date_to_check # noqa: E402
|
|
||||||
from bal.core.will import ( # noqa: E402
|
|
||||||
HeirNotFoundException,
|
|
||||||
NoHeirsException,
|
|
||||||
NotCompleteWillException,
|
|
||||||
Will,
|
|
||||||
WillItem,
|
|
||||||
WillPostponedException,
|
|
||||||
)
|
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0;
|
|
||||||
# the tests override ``tx.locktime`` to simulate the frozen signed locktime.
|
|
||||||
_VALID_TX_HEX = (
|
|
||||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
|
||||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
|
||||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
|
||||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
|
||||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
|
||||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
|
||||||
"42146f11ef8414ae929feaafc388ac00000000"
|
|
||||||
)
|
|
||||||
|
|
||||||
# The frozen tx.locktime of karen7's valid item: delivery 2027-08-05, i.e. a
|
|
||||||
# will built 2026-08-05 with a "1y" recipe.
|
|
||||||
_FROZEN = 1817438400
|
|
||||||
|
|
||||||
|
|
||||||
def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|
||||||
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
|
|
||||||
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
|
||||||
d = {
|
|
||||||
"tx": _VALID_TX_HEX,
|
|
||||||
"heirs": copy.deepcopy(heirs),
|
|
||||||
"willexecutor": None,
|
|
||||||
"status": "",
|
|
||||||
"description": "",
|
|
||||||
"time": 0,
|
|
||||||
"change": "",
|
|
||||||
"baltx_fees": 1,
|
|
||||||
}
|
|
||||||
item = WillItem(d, _id="willid_1")
|
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
|
||||||
item.tx.locktime = tx_locktime
|
|
||||||
if status_complete:
|
|
||||||
item.set_status("COMPLETE", True)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
def _run_heir_check(will_heirs, current_heirs, tx_locktime, status_complete):
|
|
||||||
"""Run ``check_willexecutors_and_heirs`` and return the outcome."""
|
|
||||||
item = _make_will_item(will_heirs, tx_locktime, status_complete)
|
|
||||||
will = {"willid_1": item}
|
|
||||||
try:
|
|
||||||
result = Will.check_willexecutors_and_heirs(
|
|
||||||
will, current_heirs, {}, False, 0, 1
|
|
||||||
)
|
|
||||||
return f"coherent ({result})"
|
|
||||||
except WillPostponedException as e:
|
|
||||||
return f"POSTPONE: {e}"
|
|
||||||
except HeirNotFoundException as e:
|
|
||||||
return f"rebuild: {e}"
|
|
||||||
except NoHeirsException as e:
|
|
||||||
return f"NoHeirs: {e}"
|
|
||||||
except NotCompleteWillException as e:
|
|
||||||
return f"NotComplete: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unchanged_relative_recipe_signed_is_coherent():
|
|
||||||
"""The reported bug: an unchanged "1y" recipe on a signed will must NOT be
|
|
||||||
read as a postpone just because the clock has advanced past build day."""
|
|
||||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
|
||||||
outcome = _run_heir_check(
|
|
||||||
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=True
|
|
||||||
)
|
|
||||||
assert outcome.startswith("coherent"), outcome
|
|
||||||
|
|
||||||
|
|
||||||
def test_unchanged_relative_recipe_unsigned_is_coherent():
|
|
||||||
heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
|
||||||
outcome = _run_heir_check(
|
|
||||||
copy.deepcopy(heirs), copy.deepcopy(heirs), _FROZEN, status_complete=False
|
|
||||||
)
|
|
||||||
assert outcome.startswith("coherent"), outcome
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_recipe_lengthened_on_signed_is_postpone():
|
|
||||||
"""A genuinely lengthened recipe ("1y" -> "2y") on a signed/sent will is
|
|
||||||
still detected as a postpone (must invalidate on-chain first)."""
|
|
||||||
built = {"alice": ["addr_alice", 5000, "1y"]}
|
|
||||||
now = {"alice": ["addr_alice", 5000, "2y"]}
|
|
||||||
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
|
|
||||||
assert outcome.startswith("POSTPONE"), outcome
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_recipe_shortened_on_signed_is_rebuild():
|
|
||||||
"""A shortened recipe ("1y" -> "30d") is an ANTICIPATE: plain rebuild, no
|
|
||||||
on-chain invalidation."""
|
|
||||||
built = {"alice": ["addr_alice", 5000, "1y"]}
|
|
||||||
now = {"alice": ["addr_alice", 5000, "30d"]}
|
|
||||||
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
|
|
||||||
assert outcome.startswith("rebuild"), outcome
|
|
||||||
|
|
||||||
|
|
||||||
def test_unchanged_absolute_recipe_is_coherent():
|
|
||||||
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
|
||||||
outcome = _run_heir_check(
|
|
||||||
copy.deepcopy(built), copy.deepcopy(built), _FROZEN, status_complete=True
|
|
||||||
)
|
|
||||||
assert outcome.startswith("coherent"), outcome
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_postpone_on_signed_still_detected():
|
|
||||||
built = {"alice": ["addr_alice", 5000, str(_FROZEN)]}
|
|
||||||
now = {"alice": ["addr_alice", 5000, str(_FROZEN + 86400)]}
|
|
||||||
outcome = _run_heir_check(built, now, _FROZEN, status_complete=True)
|
|
||||||
assert outcome.startswith("POSTPONE"), outcome
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# karen7 wallet regression (real fixture)
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
|
|
||||||
def _load_karen7():
|
|
||||||
path = os.path.join(os.path.dirname(__file__), "karen7")
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def test_karen7_frozen_delivery_not_expired():
|
|
||||||
"""ADVANCED date_to_check anchored to the frozen tx locktime: the check
|
|
||||||
window opens BEFORE the delivery, so the will is never read as expired."""
|
|
||||||
data = _load_karen7()
|
|
||||||
will_settings = data["will_settings"]
|
|
||||||
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
|
||||||
built_locktime = Will.get_min_locktime({valid_wid: wi})
|
|
||||||
assert built_locktime == _FROZEN
|
|
||||||
|
|
||||||
date_to_check = resolve_date_to_check(
|
|
||||||
False, will_settings, now=1_800_000_000.0, built_locktime=built_locktime
|
|
||||||
)
|
|
||||||
assert int(date_to_check) < _FROZEN
|
|
||||||
# Re-evaluated 10 days later the window is identical (no daily drift).
|
|
||||||
later = resolve_date_to_check(
|
|
||||||
False, will_settings, now=1_800_000_000.0 + 10 * 86400,
|
|
||||||
built_locktime=built_locktime,
|
|
||||||
)
|
|
||||||
assert date_to_check == later
|
|
||||||
|
|
||||||
|
|
||||||
def test_karen7_unchanged_heirs_are_coherent():
|
|
||||||
"""The karen7 heirs (unchanged relative "1y") are coherent with the frozen
|
|
||||||
signed tx: the plugin must NOT ask to invalidate the will."""
|
|
||||||
data = _load_karen7()
|
|
||||||
valid_wid = "def15833cf94c5795c6275076bdadebd809175455f6eb4d5f8db304f816433b8"
|
|
||||||
wi = WillItem(data["will"][valid_wid], _id=valid_wid)
|
|
||||||
date_to_check = resolve_date_to_check(
|
|
||||||
False, data["will_settings"],
|
|
||||||
now=1_800_000_000.0,
|
|
||||||
built_locktime=int(wi.tx.locktime),
|
|
||||||
)
|
|
||||||
outcome = _run_heir_check(
|
|
||||||
data["will"][valid_wid]["heirs"],
|
|
||||||
data["heirs"],
|
|
||||||
int(wi.tx.locktime),
|
|
||||||
status_complete=True,
|
|
||||||
)
|
|
||||||
assert outcome.startswith("coherent"), outcome
|
|
||||||
assert int(date_to_check) < int(wi.tx.locktime)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All heir-relative-anchor tests passed")
|
|
||||||
@@ -1,603 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the "Import" (read-only will preview) flow.
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
|
|
||||||
- ``import_will_into_details`` normalization + IMPORTED status.
|
|
||||||
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
|
|
||||||
will, never on the live wallet state.
|
|
||||||
- ``WillWidget`` honouring an explicit ``will`` argument.
|
|
||||||
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
|
|
||||||
|
|
||||||
Run:
|
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from types import MethodType, SimpleNamespace
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QWidget
|
|
||||||
|
|
||||||
from bal.core.will import Will, WillItem
|
|
||||||
from bal.gui.qt import window as window_mod
|
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
|
||||||
|
|
||||||
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
|
|
||||||
_VALID_TX_HEX = (
|
|
||||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
|
||||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
|
||||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
|
||||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
|
||||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
|
||||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
|
||||||
"42146f11ef8414ae929feaafc388ac00000000"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_willitem_dict(**overrides):
|
|
||||||
"""Return a minimal dict that can construct a WillItem."""
|
|
||||||
d = {
|
|
||||||
"tx": _VALID_TX_HEX,
|
|
||||||
"heirs": {},
|
|
||||||
"willexecutor": None,
|
|
||||||
"status": "",
|
|
||||||
"description": "",
|
|
||||||
"time": 0,
|
|
||||||
"change": "",
|
|
||||||
"baltx_fees": 100,
|
|
||||||
}
|
|
||||||
d.update(overrides)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _make_willitems(n, prefix="w"):
|
|
||||||
"""Create ``n`` WillItems with distinct txids/locktimes."""
|
|
||||||
willitems = {}
|
|
||||||
for i in range(n):
|
|
||||||
wid = f"{prefix}{i}"
|
|
||||||
wi = WillItem(_make_willitem_dict())
|
|
||||||
wi.tx.locktime = 1000 + i
|
|
||||||
wi._id = wid
|
|
||||||
willitems[wid] = wi
|
|
||||||
return willitems
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# _load_will_file round-trip
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_load_will_file_roundtrip():
|
|
||||||
from bal.gui.qt.common import write_json_file
|
|
||||||
|
|
||||||
src = _make_willitems(2)
|
|
||||||
data = {wid: wi.to_dict() for wid, wi in src.items()}
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w", suffix=".json", delete=False
|
|
||||||
) as f:
|
|
||||||
path = f.name
|
|
||||||
write_json_file(path, data)
|
|
||||||
try:
|
|
||||||
loaded = window_mod.BalWindow._load_will_file(None, path)
|
|
||||||
assert set(loaded) == {"w0", "w1"}
|
|
||||||
for wid, wi in loaded.items():
|
|
||||||
assert isinstance(wi, WillItem)
|
|
||||||
assert wi.heirs == {}
|
|
||||||
assert wi._id == wid
|
|
||||||
assert wi.tx is not None
|
|
||||||
finally:
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# import_will_into_details normalization + IMPORTED status
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_imported_will_is_normalized_and_marked_imported():
|
|
||||||
willitems = _make_willitems(1)
|
|
||||||
Will.normalize_will(willitems, None)
|
|
||||||
for wi in willitems.values():
|
|
||||||
wi.set_status("IMPORTED", True)
|
|
||||||
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
|
|
||||||
assert all(wi.get_status("VALID") for wi in willitems.values())
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# sign_transactions operates only on the passed (imported) will
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_sign_transactions_external_only():
|
|
||||||
class FakeWallet:
|
|
||||||
def sign_transaction(self, tx, password, ignore_warnings=False):
|
|
||||||
# No-op: never marks the tx complete.
|
|
||||||
return None
|
|
||||||
|
|
||||||
live = _make_willitems(1, prefix="live")
|
|
||||||
wid_live = next(iter(live))
|
|
||||||
imported = _make_willitems(2, prefix="imp")
|
|
||||||
|
|
||||||
fake = SimpleNamespace(
|
|
||||||
willitems=live,
|
|
||||||
wallet=FakeWallet(),
|
|
||||||
waiting_dialog=SimpleNamespace(update=lambda msg: None),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
assert set(result) == set(imported)
|
|
||||||
assert wid_live not in result
|
|
||||||
# The live will must be completely untouched by the external sign run.
|
|
||||||
assert live[wid_live].get_status("COMPLETE") is False
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# WillWidget honours an explicit ``will`` argument
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_will_widget_explicit_will():
|
|
||||||
from bal.gui.qt.widgets import WillWidget
|
|
||||||
|
|
||||||
live = _make_willitems(1, prefix="live")
|
|
||||||
imported = _make_willitems(2, prefix="imp")
|
|
||||||
|
|
||||||
fake_parent = SimpleNamespace(
|
|
||||||
decimal_point=8,
|
|
||||||
base_unit_name="BTC",
|
|
||||||
bal_window=SimpleNamespace(
|
|
||||||
willitems=live,
|
|
||||||
bal_plugin=SimpleNamespace(
|
|
||||||
_hide_replaced=False, _hide_invalidated=False
|
|
||||||
),
|
|
||||||
show_transaction=lambda *a, **k: None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
w = WillWidget(parent=fake_parent, will=imported)
|
|
||||||
assert w.will is imported
|
|
||||||
|
|
||||||
w2 = WillWidget(parent=fake_parent)
|
|
||||||
assert w2.will is live
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# WillDetailDialog external-will mode
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def _make_fake_bal_window(window_widget):
|
|
||||||
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
|
|
||||||
return SimpleNamespace(
|
|
||||||
window=window_widget,
|
|
||||||
bal_plugin=bal_plugin,
|
|
||||||
wallet=SimpleNamespace(),
|
|
||||||
show_transaction=lambda *a, **k: None,
|
|
||||||
willitems=_make_willitems(1),
|
|
||||||
will_settings={"real_threshold": 9999},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_will_detail_dialog_external_threshold():
|
|
||||||
from bal.gui.qt.dialogs import WillDetailDialog
|
|
||||||
|
|
||||||
window_widget = QWidget()
|
|
||||||
bal_window = _make_fake_bal_window(window_widget)
|
|
||||||
bal_window.window.config = SimpleNamespace()
|
|
||||||
bal_window.window.format_amount = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.base_unit = "BTC"
|
|
||||||
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.fx = None
|
|
||||||
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.get_decimal_point = lambda: 8
|
|
||||||
|
|
||||||
imported = _make_willitems(2)
|
|
||||||
# locktimes 1000 and 1001 -> threshold must be the max (1001).
|
|
||||||
dialog = WillDetailDialog(bal_window, will=imported)
|
|
||||||
|
|
||||||
assert dialog._external_will is True
|
|
||||||
assert dialog.will is imported
|
|
||||||
assert dialog.threshold == 1001
|
|
||||||
|
|
||||||
dialog2 = WillDetailDialog(bal_window)
|
|
||||||
assert dialog2._external_will is False
|
|
||||||
assert dialog2.will is bal_window.willitems
|
|
||||||
assert dialog2.threshold == 9999
|
|
||||||
|
|
||||||
|
|
||||||
def test_will_detail_dialog_buttons_pass_will():
|
|
||||||
from bal.gui.qt.dialogs import WillDetailDialog
|
|
||||||
|
|
||||||
window_widget = QWidget()
|
|
||||||
bal_window = _make_fake_bal_window(window_widget)
|
|
||||||
bal_window.window.config = SimpleNamespace()
|
|
||||||
bal_window.window.format_amount = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.base_unit = "BTC"
|
|
||||||
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.fx = None
|
|
||||||
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.get_decimal_point = lambda: 8
|
|
||||||
|
|
||||||
imported = _make_willitems(1)
|
|
||||||
dialog = WillDetailDialog(bal_window, will=imported)
|
|
||||||
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
|
|
||||||
("sign", k.get("will"))
|
|
||||||
)
|
|
||||||
bal_window.broadcast_transactions = lambda **k: calls.append(
|
|
||||||
("broadcast", k.get("will"))
|
|
||||||
)
|
|
||||||
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
|
|
||||||
bal_window.invalidate_will = lambda **k: calls.append(
|
|
||||||
("invalidate", k.get("will"))
|
|
||||||
)
|
|
||||||
|
|
||||||
dialog.ask_password_and_sign_transactions()
|
|
||||||
dialog.broadcast_transactions()
|
|
||||||
dialog.export_will()
|
|
||||||
dialog.invalidate_will()
|
|
||||||
|
|
||||||
assert len(calls) == 4
|
|
||||||
for action, will in calls:
|
|
||||||
assert will is imported, f"{action} did not receive the imported will"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Merge flow (BalWindow.merge_will)
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def _make_partial_tx(locktime=1000, signed=False):
|
|
||||||
"""A PartialTransaction derived from _VALID_TX_HEX.
|
|
||||||
|
|
||||||
When ``signed`` the input scriptSig of the raw tx is copied over, which
|
|
||||||
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
|
|
||||||
"""
|
|
||||||
from electrum.transaction import PartialTransaction, Transaction
|
|
||||||
|
|
||||||
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
|
|
||||||
ptx.locktime = locktime
|
|
||||||
if signed:
|
|
||||||
raw = Transaction(_VALID_TX_HEX)
|
|
||||||
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
|
|
||||||
return ptx
|
|
||||||
|
|
||||||
|
|
||||||
def _make_willitem_with_tx(tx, key=None):
|
|
||||||
wi = WillItem(_make_willitem_dict())
|
|
||||||
wi.tx = tx
|
|
||||||
wi._id = key if key is not None else tx.txid()
|
|
||||||
return wi
|
|
||||||
|
|
||||||
|
|
||||||
def _make_merge_fake(willitems):
|
|
||||||
"""A BalWindow-like object with a wallet stub sufficient for the local
|
|
||||||
validity check that ``merge_will`` runs after merging.
|
|
||||||
"""
|
|
||||||
class FakeWallet:
|
|
||||||
def add_input_info(self, txin, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def add_output_info(self, txout, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_utxos(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_tx_info(self, tx):
|
|
||||||
return SimpleNamespace(
|
|
||||||
tx_mined_status=SimpleNamespace(height=lambda: 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def db(self):
|
|
||||||
return SimpleNamespace(get_transaction=lambda txid: None)
|
|
||||||
|
|
||||||
calls = []
|
|
||||||
fake = SimpleNamespace(
|
|
||||||
willitems=willitems,
|
|
||||||
will={},
|
|
||||||
wallet=FakeWallet(),
|
|
||||||
bal_window=None,
|
|
||||||
date_to_check=1700000000,
|
|
||||||
will_settings={"threshold": 1700000000},
|
|
||||||
bal_plugin=SimpleNamespace(
|
|
||||||
HISTORY_LABEL=SimpleNamespace(
|
|
||||||
get=lambda: "BAL will history ({willexecutor})"
|
|
||||||
),
|
|
||||||
# BASIC is the default user type; merge_will resolves date_to_check
|
|
||||||
# through bal.core.checkalive when it is missing.
|
|
||||||
is_basic_mode=lambda: True,
|
|
||||||
),
|
|
||||||
update_all=lambda: calls.append("update_all"),
|
|
||||||
)
|
|
||||||
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
|
|
||||||
return fake, calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
|
|
||||||
# The realistic "signed on another machine, imported here" case: the live
|
|
||||||
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
|
|
||||||
# (complete). The transaction must be substituted and COMPLETE set.
|
|
||||||
wid = "same_id"
|
|
||||||
live_tx = _make_partial_tx(locktime=1000, signed=False)
|
|
||||||
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
|
|
||||||
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
imported[wid].set_status("COMPLETE", True)
|
|
||||||
imported[wid].set_status("PUSHED", True)
|
|
||||||
imported[wid].set_status("CHECKED", True)
|
|
||||||
|
|
||||||
fake, calls = _make_merge_fake(live)
|
|
||||||
live_item = live[wid]
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
# The live WillItem object is kept (never replaced) and the signed tx
|
|
||||||
# substituted in.
|
|
||||||
assert live[wid] is live_item
|
|
||||||
assert live_item.tx is imported_tx
|
|
||||||
assert live_item.tx.is_complete()
|
|
||||||
assert live_item.get_status("COMPLETE") is True
|
|
||||||
# Operational statuses were carried over.
|
|
||||||
assert live_item.get_status("PUSHED") is True
|
|
||||||
assert live_item.get_status("CHECKED") is True
|
|
||||||
# The will was saved and the GUI refreshed.
|
|
||||||
assert wid in fake.will
|
|
||||||
assert fake.will[wid]["COMPLETE"] is True
|
|
||||||
assert "update_all" in calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_same_id_both_signed_combines():
|
|
||||||
# Live tx is signed but the COMPLETE flag was not set yet; the imported
|
|
||||||
# signed tx with the same txid must be COMBINED into the live one (the live
|
|
||||||
# tx object is kept) rather than substituted.
|
|
||||||
from electrum.transaction import Transaction
|
|
||||||
|
|
||||||
wid = "same_id_combine"
|
|
||||||
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
txid = imported_tx.txid()
|
|
||||||
assert txid is not None
|
|
||||||
|
|
||||||
live_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
imported[wid].set_status("COMPLETE", True)
|
|
||||||
|
|
||||||
fake, _ = _make_merge_fake(live)
|
|
||||||
live_item = live[wid]
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
# Same txid -> combine_with_other_psbt: live tx object is preserved.
|
|
||||||
assert live_item.tx is live_tx
|
|
||||||
assert live_item.tx.is_complete()
|
|
||||||
assert live_item.get_status("COMPLETE") is True
|
|
||||||
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
|
|
||||||
assert live_item.tx.inputs()[0].script_sig == raw_sig
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_same_id_already_complete_never_touches_live_tx():
|
|
||||||
wid = "already_complete"
|
|
||||||
live_tx = _make_partial_tx(locktime=1000, signed=False)
|
|
||||||
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
|
||||||
live[wid].set_status("COMPLETE", True)
|
|
||||||
|
|
||||||
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
imported[wid].set_status("COMPLETE", True)
|
|
||||||
imported[wid].set_status("PUSHED", True)
|
|
||||||
|
|
||||||
fake, _ = _make_merge_fake(live)
|
|
||||||
live_item = live[wid]
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
# An already-signed live will is left untouched: no combine, no substitute.
|
|
||||||
assert live_item.tx is live_tx
|
|
||||||
assert live_item.tx.is_complete() is False
|
|
||||||
assert live_item.tx.inputs()[0].script_sig is None
|
|
||||||
assert live_item.get_status("COMPLETE") is True
|
|
||||||
assert live_item.get_status("PUSHED") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_statuses_are_monotonic():
|
|
||||||
# Statuses that are True in the live item must never be cleared by a False
|
|
||||||
# value coming from the imported item.
|
|
||||||
wid = "monotonic"
|
|
||||||
live_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
|
||||||
live[wid].set_status("CONFIRMED", True)
|
|
||||||
|
|
||||||
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
imported[wid].set_status("MEMPOOL", True)
|
|
||||||
|
|
||||||
fake, _ = _make_merge_fake(live)
|
|
||||||
live_item = live[wid]
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
assert live_item.get_status("CONFIRMED") is True
|
|
||||||
assert live_item.get_status("MEMPOOL") is True
|
|
||||||
assert live_item.get_status("COMPLETE") is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_new_ids_added_wholesale():
|
|
||||||
live_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
|
|
||||||
|
|
||||||
imported_tx = _make_partial_tx(locktime=2000, signed=True)
|
|
||||||
new_id = imported_tx.txid()
|
|
||||||
imported = {new_id: _make_willitem_with_tx(imported_tx)}
|
|
||||||
imported[new_id].set_status("PUSHED", True)
|
|
||||||
|
|
||||||
fake, _ = _make_merge_fake(live)
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
assert len(live) == 2
|
|
||||||
assert live[new_id] is imported[new_id]
|
|
||||||
assert live[new_id].get_status("PUSHED") is True
|
|
||||||
assert new_id in fake.will
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_missing_date_to_check_defaults_to_now():
|
|
||||||
# Regression: merge_will read self.date_to_check (which is only set by
|
|
||||||
# init_class_variables) and crashed with AttributeError when merging a
|
|
||||||
# will file was the first action of a session.
|
|
||||||
wid = "missing_dtc"
|
|
||||||
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
fake, calls = _make_merge_fake({})
|
|
||||||
del fake.date_to_check
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
assert isinstance(fake.date_to_check, (int, float)), \
|
|
||||||
"date_to_check must fall back to a timestamp"
|
|
||||||
assert wid in fake.will
|
|
||||||
assert "update_all" in calls
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_validity_error_logs_without_crashing():
|
|
||||||
# Regression: the except handler passed log_error(e, self.bal_window);
|
|
||||||
# BalWindow has no such attribute, so a validity failure raised a second
|
|
||||||
# AttributeError that masked the original error.
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
wid = "validity_err"
|
|
||||||
imported_tx = _make_partial_tx(locktime=1000, signed=True)
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
fake, calls = _make_merge_fake({})
|
|
||||||
fake.show_error = lambda msg: calls.append(("show_error", msg))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
window_mod.Util, "get_available_utxos", side_effect=RuntimeError("boom")
|
|
||||||
):
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
assert any(c[0] == "show_error" for c in calls), \
|
|
||||||
"the validity error must be surfaced via show_error"
|
|
||||||
assert "update_all" in calls, "merge must complete after the error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_from_file_invalid_file_raises():
|
|
||||||
from bal.gui.qt.common import FileImportFailed
|
|
||||||
|
|
||||||
def bad_load(path):
|
|
||||||
raise ValueError("bad file")
|
|
||||||
|
|
||||||
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
|
|
||||||
except FileImportFailed as e:
|
|
||||||
assert "bad file" in str(e)
|
|
||||||
else:
|
|
||||||
raise AssertionError("expected FileImportFailed")
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_will_partial_signatures_update_counts():
|
|
||||||
# A live unsigned 2-of-3 multisig will merged with an imported copy that
|
|
||||||
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
|
|
||||||
# refreshed on the live item (check_signatures runs after the merge).
|
|
||||||
from binascii import unhexlify
|
|
||||||
|
|
||||||
from electrum import crypto
|
|
||||||
from electrum.descriptor import parse_descriptor
|
|
||||||
from electrum.transaction import (
|
|
||||||
PartialTransaction,
|
|
||||||
PartialTxInput,
|
|
||||||
PartialTxOutput,
|
|
||||||
Sighash,
|
|
||||||
TxOutpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
def make_multisig_tx(nsigs):
|
|
||||||
pubs = []
|
|
||||||
for seed in (1, 2, 3):
|
|
||||||
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
|
|
||||||
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
|
|
||||||
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
|
|
||||||
txin.script_descriptor = desc
|
|
||||||
txin._trusted_value_sats = 100000
|
|
||||||
txin.sighash = Sighash.ALL
|
|
||||||
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
|
|
||||||
for i in range(nsigs):
|
|
||||||
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
|
|
||||||
from electrum.bitcoin import public_key_to_p2wpkh
|
|
||||||
|
|
||||||
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
|
|
||||||
ptx = PartialTransaction()
|
|
||||||
ptx.add_inputs([txin])
|
|
||||||
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
|
|
||||||
ptx.locktime = 1000
|
|
||||||
return ptx, desc
|
|
||||||
|
|
||||||
wid = "multisig_will"
|
|
||||||
live_tx, _ = make_multisig_tx(0)
|
|
||||||
imported_tx, _ = make_multisig_tx(1)
|
|
||||||
|
|
||||||
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
|
|
||||||
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
|
|
||||||
|
|
||||||
fake, _ = _make_merge_fake(live)
|
|
||||||
live_item = live[wid]
|
|
||||||
|
|
||||||
window_mod.BalWindow.merge_will(fake, imported)
|
|
||||||
|
|
||||||
assert live_item.get_status("PARTIALLY_SIGNED") is True
|
|
||||||
assert live_item.sigs_have == 1
|
|
||||||
assert live_item.sigs_required == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_will_detail_dialog_merge_switches_to_live():
|
|
||||||
from bal.gui.qt.dialogs import WillDetailDialog
|
|
||||||
|
|
||||||
window_widget = QWidget()
|
|
||||||
bal_window = _make_fake_bal_window(window_widget)
|
|
||||||
bal_window.window.config = SimpleNamespace()
|
|
||||||
bal_window.window.format_amount = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.base_unit = "BTC"
|
|
||||||
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.fx = None
|
|
||||||
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
|
|
||||||
bal_window.window.get_decimal_point = lambda: 8
|
|
||||||
|
|
||||||
live = bal_window.willitems
|
|
||||||
imported = _make_willitems(2)
|
|
||||||
merged = []
|
|
||||||
bal_window.merge_will = lambda will: merged.append(will)
|
|
||||||
|
|
||||||
dialog = WillDetailDialog(bal_window, will=imported)
|
|
||||||
|
|
||||||
assert dialog._external_will is True
|
|
||||||
assert dialog.merge_button is not None
|
|
||||||
assert dialog.merge_button.isHidden() is False
|
|
||||||
|
|
||||||
dialog.merge_will()
|
|
||||||
|
|
||||||
assert merged == [imported]
|
|
||||||
assert dialog._external_will is False
|
|
||||||
assert dialog.will is live
|
|
||||||
assert dialog.threshold == bal_window.will_settings["real_threshold"]
|
|
||||||
assert dialog.merge_button.isHidden() is True
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Main
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All import-will-details tests passed")
|
|
||||||
@@ -1,612 +0,0 @@
|
|||||||
"""
|
|
||||||
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,15 +23,9 @@ 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.plugin_base import BalPlugin, BalTimestamp
|
|
||||||
from bal.core.will import (
|
|
||||||
NoHeirsException,
|
|
||||||
NotCompleteWillException,
|
|
||||||
NoWillExecutorNotPresent,
|
|
||||||
Will,
|
|
||||||
WillItem,
|
|
||||||
)
|
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
from bal.core.will import Will, WillItem, NotCompleteWillException, NoHeirsException, NoWillExecutorNotPresent
|
||||||
|
from bal.core.plugin_base import BalPlugin, BalTimestamp
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Load karen7 wallet data
|
# Load karen7 wallet data
|
||||||
@@ -59,7 +53,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 _, out_map in outputs.items():
|
for addr, 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():
|
||||||
@@ -91,6 +85,7 @@ 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()
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
"""
|
|
||||||
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")
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs``.
|
|
||||||
|
|
||||||
This is the post-build sync that keeps the plugin's stored delivery date
|
|
||||||
(WILL_SETTINGS["locktime"]) and check-alive threshold in lockstep with the
|
|
||||||
BUILT transactions' fixed locktime. The bug it fixes (reported by the owner):
|
|
||||||
|
|
||||||
ADVANCED mode + RELATIVE locktime ("90d") / threshold ("30d") -> the plugin
|
|
||||||
asks to invalidate the will EVERY DAY. The relative value is re-parsed
|
|
||||||
against "now" on every check, so it drifts one day per day away from the
|
|
||||||
fixed tx locktime and the postpone check always sees a "postpone".
|
|
||||||
|
|
||||||
The method is exercised with a lightweight fake ``self`` (no Qt event loop, no
|
|
||||||
Electrum wallet) by calling it as an unbound method.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
source /home/steal/devel/bal/electrum/env/bin/activate
|
|
||||||
python3 tests/test_sync_locktime_built_txs.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|
||||||
|
|
||||||
from bal.core.plugin_base import BalTimestamp # noqa: E402 (path insert above)
|
|
||||||
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Fakes
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def _fake_willitem(tx_locktime):
|
|
||||||
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime."""
|
|
||||||
return SimpleNamespace(
|
|
||||||
tx=SimpleNamespace(locktime=tx_locktime),
|
|
||||||
get_status=lambda name: True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_dialog(will_settings, tx_locktimes, recorded):
|
|
||||||
"""Build a fake dialog ``self`` for _sync_locktime_to_built_txs."""
|
|
||||||
|
|
||||||
def update_setting_widgets(new_value, field, update_all=False):
|
|
||||||
will_settings[field] = new_value
|
|
||||||
recorded.append((field, new_value, update_all))
|
|
||||||
|
|
||||||
return SimpleNamespace(
|
|
||||||
bal_window=SimpleNamespace(
|
|
||||||
willitems={
|
|
||||||
f"tx{i}": _fake_willitem(lt) for i, lt in enumerate(tx_locktimes)
|
|
||||||
},
|
|
||||||
will_settings=will_settings,
|
|
||||||
update_setting_widgets=update_setting_widgets,
|
|
||||||
),
|
|
||||||
_date_was_anticipated=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _call_sync(will_settings, tx_locktimes, recorded):
|
|
||||||
fake = _make_dialog(will_settings, tx_locktimes, recorded)
|
|
||||||
BalBuildWillDialog._sync_locktime_to_built_txs(fake)
|
|
||||||
return fake
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Tests
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_relative_locktime_normalized_to_absolute():
|
|
||||||
"""The reported bug: a relative stored locktime is frozen to the absolute
|
|
||||||
value of the built transaction, even when it parses to the same moment."""
|
|
||||||
tx_locktime = 1_800_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
|
||||||
assert fake.bal_window.will_settings["locktime"] != "90d"
|
|
||||||
# A pure relative->absolute normalisation is NOT an anticipation: the sign
|
|
||||||
# prompt must not claim the date was anticipated.
|
|
||||||
assert fake._date_was_anticipated is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_threshold_frozen_to_absolute():
|
|
||||||
"""A relative threshold ("N days BEFORE the delivery") is normalised to the
|
|
||||||
same absolute value the settings widget computes (real_threshold)."""
|
|
||||||
tx_locktime = 1_800_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
|
||||||
)
|
|
||||||
expected = int(
|
|
||||||
BalTimestamp("30d").to_date(tx_locktime, reverse=True).timestamp()
|
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["threshold"] == expected
|
|
||||||
assert ("threshold", expected, True) in recorded
|
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_locktime_unchanged_on_equal():
|
|
||||||
"""An absolute stored locktime that already matches the built txs is left
|
|
||||||
untouched (no spurious rewrite)."""
|
|
||||||
tx_locktime = 1_800_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": tx_locktime, "threshold": 1_700_000_000},
|
|
||||||
[tx_locktime],
|
|
||||||
recorded,
|
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
|
||||||
assert fake._date_was_anticipated is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_anticipation_sets_flag_and_moves_earlier():
|
|
||||||
"""A real anticipation (built locktime earlier than the stored absolute
|
|
||||||
one) still moves the date earlier and flags the sign prompt."""
|
|
||||||
tx_locktime = 1_700_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
|
|
||||||
[tx_locktime],
|
|
||||||
recorded,
|
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["locktime"] == tx_locktime
|
|
||||||
assert fake._date_was_anticipated is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_stored_earlier_than_built_never_moved_later():
|
|
||||||
"""A stored absolute date that is already EARLIER than the built txs (the
|
|
||||||
user moved the delivery later) is never pulled back up on rebuild: only
|
|
||||||
anticipation (built < stored) and relative normalisation move the value."""
|
|
||||||
stored = 1_800_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": stored, "threshold": 1_700_000_000},
|
|
||||||
[1_900_000_000],
|
|
||||||
recorded,
|
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["locktime"] == stored
|
|
||||||
assert fake._date_was_anticipated is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_txs_uses_minimum_locktime():
|
|
||||||
"""When several transactions carry different locktimes, the minimum is used
|
|
||||||
(owner-confirmed behaviour for the delivery date shown in the UI)."""
|
|
||||||
min_locktime = 1_750_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": "90d", "threshold": "30d"},
|
|
||||||
[min_locktime, min_locktime + 86_400],
|
|
||||||
recorded,
|
|
||||||
)
|
|
||||||
assert fake.bal_window.will_settings["locktime"] == min_locktime
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_locktime_stops_daily_postpone():
|
|
||||||
"""End-to-end guard for the reported bug: after the sync, re-parsing the
|
|
||||||
stored (now absolute) locktime on later days always equals the built
|
|
||||||
tx locktime, so the postpone check never fires again."""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from bal.core.util import Util
|
|
||||||
|
|
||||||
tx_locktime = 1_800_000_000
|
|
||||||
recorded = []
|
|
||||||
fake = _call_sync(
|
|
||||||
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
|
|
||||||
)
|
|
||||||
stored = fake.bal_window.will_settings["locktime"]
|
|
||||||
for _day in range(0, 7):
|
|
||||||
# Simulate the check on later days: parse the STORED value (which is
|
|
||||||
# now the absolute tx locktime) and compare with the fixed tx locktime.
|
|
||||||
new_locktime = Util.parse_locktime_string(stored)
|
|
||||||
assert new_locktime == tx_locktime
|
|
||||||
assert new_locktime <= tx_locktime # no POSTPONE / drift
|
|
||||||
# Sanity: a RELATIVE value would have drifted past it (the bug).
|
|
||||||
drifted = int(
|
|
||||||
(
|
|
||||||
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
|
|
||||||
).timestamp()
|
|
||||||
)
|
|
||||||
assert drifted > tx_locktime
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
for name in sorted(dir()):
|
|
||||||
if name.startswith("test_"):
|
|
||||||
globals()[name]()
|
|
||||||
print(f" [OK] {name}")
|
|
||||||
print("[OK] All sync_locktime_built_txs tests passed.")
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"""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")
|
||||||
bt_class = plugin_base.BalTimestamp
|
BalTimestamp = 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 = bt_class(NLOCKTIME_MAX)
|
bt = BalTimestamp(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 = bt_class(NLOCKTIME_MAX)
|
bt = BalTimestamp(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 = bt_class(f"{10 ** 9}d") # ~2.7M years -> overflow
|
bt_rel = BalTimestamp(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 = bt_class("90d")
|
bt_norm = BalTimestamp("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