Compare commits
44 Commits
bitcoinaft
...
827a1d1e54
| Author | SHA1 | Date | |
|---|---|---|---|
|
827a1d1e54
|
|||
|
d6fd20ad9c
|
|||
|
5ec2b79d03
|
|||
|
47b120f526
|
|||
|
1b601609f0
|
|||
|
b1f78700d1
|
|||
|
beab22ce23
|
|||
| 70196fc3cd | |||
| 9c654bf2bf | |||
| e8db76338d | |||
|
4a33116b66
|
|||
|
fb88d7540c
|
|||
| 9c4697c923 | |||
| 42f05d3c4f | |||
| 3a9ee5adb9 | |||
| 2c9f6bdd9d | |||
| 4dfb3fc41c | |||
| 1ae1617172 | |||
| 125bf09b9a | |||
| b5752a42f0 | |||
| ce659048ca | |||
| c80f0e1524 | |||
| e8e4d5e451 | |||
| e74c348bbb | |||
| fdcab7c6bd | |||
| eac03da530 | |||
| 167d1228b2 | |||
| 8649556a18 | |||
| 5e53a87bb6 | |||
| 251fd93cbd | |||
| bdeef3dc6a | |||
| 6f7a364bc6 | |||
| a1b13947c3 | |||
| 6e0ae7e4ed | |||
| 9275e9c6a2 | |||
| 658e3dca0b | |||
| c82b5dda37 | |||
| deca1a548e | |||
| db573aa1b3 | |||
| 326b3f6f76 | |||
| 943731c070 | |||
|
|
40675ac986 | ||
|
|
73be102f7c | ||
| 37a417ae59 |
36
.gitignore
vendored
36
.gitignore
vendored
@@ -4,3 +4,39 @@
|
|||||||
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.*
|
||||||
|
tests/karen7
|
||||||
|
|
||||||
|
# Compiled translations: build_zip.py rebuilds them from the .po sources
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Android companion app: kept locally only, not part of the plugin repo
|
||||||
|
android/
|
||||||
|
|||||||
231
.opencode/plans/plan_android_reader.md
Normal file
231
.opencode/plans/plan_android_reader.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# PLAN_ANDROID_READER.md — BAL Reader: an Android QR will reader
|
||||||
|
|
||||||
|
**Date:** 2026-09-09 · **Status:** proposed · **Ties into:** BAL plugin QR export (BALQR / BC-UR v1 / BC-UR v2 / BBQR)
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Ship a minimal single-activity Android app that reads any QR will exported by the BAL
|
||||||
|
Electrum plugin, decodes all four supported wire formats, and lets the user view, copy,
|
||||||
|
share, or save the recovered will data. The app is a **reader only** — it never signs or
|
||||||
|
broadcasts.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
|
||||||
|
- Continuous camera capture (CameraX + ML Kit on-device barcode scanning, QR-only).
|
||||||
|
- Auto-detection of the four formats (`detect_format`: `"balqr" / "ur1" / "ur2" / "bbqr"`).
|
||||||
|
- Order-independent frame assembly: duplicates ignored, out-of-order accepted, UR v2
|
||||||
|
XOR-fountain redundancy exploited, session-level reset on transfer switch.
|
||||||
|
- Whole-will JSON **and** tx-hex-list payloads, both decoded to a readable view.
|
||||||
|
- Copy raw transfer text / Share / Save via Storage Access Framework.
|
||||||
|
- Verified decode chain runnable on the dev machine (no Android needed).
|
||||||
|
|
||||||
|
**Out of scope**
|
||||||
|
|
||||||
|
- Signing, broadcasting, password handling, wallet integration.
|
||||||
|
- Export/authoring QR codes *from* the phone.
|
||||||
|
- Audio-modem transport (unchanged, plugin-only).
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1 Why Chaquopy (reuse the tested Python codecs)
|
||||||
|
|
||||||
|
`bal/core/animated_qr.py` (1178 lines) and `bal/core/qrtransfer.py` (212 lines) are
|
||||||
|
**pure stdlib** (`base64`, `hashlib`, `zlib`), GUI-free, Python-3.8-clean (verified: no
|
||||||
|
walrus/match/`X|Y`/PEP-585 generics), and `bal/core/__init__.py` is an empty docstring.
|
||||||
|
Chaquopy bundles CPython into the APK, so **the exact, battle-tested codec modules run
|
||||||
|
unchanged on Android** with zero port risk. The Kotlin side is only camera glue + UI.
|
||||||
|
|
||||||
|
### 3.2 Decode chain (mirror of the plugin's import tail)
|
||||||
|
|
||||||
|
The plugin's `_review_and_sign` (dialogs.py:3792) does exactly:
|
||||||
|
|
||||||
|
1. `session.resolve()` -> `(transfer_text, compressed: bool)`
|
||||||
|
(UR v1/v2/BBQR for the first pair; `(text, flag)` for BAL QR).
|
||||||
|
2. `decode_transfer(transfer_text, compressed)` -> list of parts (tx hexes, or the single
|
||||||
|
whole-will JSON).
|
||||||
|
3. `"\n".join(parts)` -> opaque payload.
|
||||||
|
4. `decode_will_payload(payload)` -> `("will", dict)` **or** `("txs", [strings])`.
|
||||||
|
|
||||||
|
The app reuses all of it verbatim through the `AnimatedQrSession` facade (`add_part`
|
||||||
|
auto-detects per frame, dedups, tracks `received/total/done`).
|
||||||
|
|
||||||
|
### 3.3 Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
CameraX ImageAnalysis -> ML Kit BarcodeScanning (QR) -> raw string
|
||||||
|
-> BalDecoder.add(text) [Chaquopy -> AnimatedQrSession.add_part]
|
||||||
|
-> status: format chip + received/total, duplicate-tolerant
|
||||||
|
-> when done: auto-stop -> BalDecoder.finish() (4-step decode)
|
||||||
|
-> ResultActivity: JSON view OR tx-list view + Copy/Share/Save
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Repository layout (new `android/` subfolder)
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
README.md # build (Android Studio), usage, codec-sync rule, MIT note
|
||||||
|
settings.gradle.kts
|
||||||
|
build.gradle.kts # root: plugins (AGP 8.10, Kotlin 2.0.21, Chaquopy 17, apply false)
|
||||||
|
gradle.properties
|
||||||
|
gradlew, gradlew.bat
|
||||||
|
gradle/wrapper/ # gradle-wrapper.properties + gradle-wrapper.jar (fetched; see §7)
|
||||||
|
scripts/
|
||||||
|
sync_codecs.py # copy bal/core/{__init__,animated_qr,qrtransfer}.py -> app python dir; import-check
|
||||||
|
test_chain/
|
||||||
|
verify_chain.py # full decode-chain simulation, runs on dev machine
|
||||||
|
app/
|
||||||
|
build.gradle.kts # com.android.application + com.chaquo.python; CameraX/ML Kit deps
|
||||||
|
src/main/
|
||||||
|
AndroidManifest.xml # CAMERA permission
|
||||||
|
java/life/after/bitcoin/
|
||||||
|
MainActivity.kt # permission + PreviewView + ImageAnalysis + status header
|
||||||
|
BalDecoder.kt # Chaquopy bridge (§5.1)
|
||||||
|
ResultActivity.kt # viewer + copy/share/save (§5.3)
|
||||||
|
res/layout/activity_main.xml, activity_result.xml
|
||||||
|
res/values/strings.xml
|
||||||
|
python/bal/core/ # SYNCED COPIES (committed, deterministic; regenerate with script)
|
||||||
|
__init__.py
|
||||||
|
animated_qr.py
|
||||||
|
qrtransfer.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Component specifications
|
||||||
|
|
||||||
|
### 5.1 `BalDecoder` (Kotlin, Chaquopy bridge)
|
||||||
|
|
||||||
|
- Lazy init: `Python.start(AndroidPlatform(context))`, `getModule("bal.core.animated_qr")`.
|
||||||
|
- `fun add(text: String): String` -> `session.add_part(text)`; surfaces `"ok"`/`"dup"`;
|
||||||
|
maps `AnimatedQrError` subclasses to a result the UI can ignore (garbage frames) vs
|
||||||
|
reset (transfer switch -> `TransferConflictError` -> tell user to rescan).
|
||||||
|
- `val format: String?` (`"balqr"/"ur1"/"ur2"/"bbqr"`), `val received: Int`,
|
||||||
|
`val total: Int`, `val done: Boolean` (auto-converted by Chaquopy).
|
||||||
|
- `fun finish(): DecodedResult` - the 4-step chain; returns
|
||||||
|
`data class DecodedResult(kind: "will"|"txs", data: Map<String,Any>|List<String>, rawTransfer: String)`.
|
||||||
|
- `fun reset()` -> new `AnimatedQrSession` (new scan).
|
||||||
|
|
||||||
|
### 5.2 `MainActivity` (camera + scan loop)
|
||||||
|
|
||||||
|
- Launches CameraX via `ProcessCameraProvider`; `PreviewView` fills screen; camera
|
||||||
|
permission via `ActivityResultContracts.RequestPermission`.
|
||||||
|
- `ImageAnalysis` `STRATEGY_KEEP_ONLY_LATEST`; analyzer throttled (~100 ms) calls ML Kit
|
||||||
|
`BarcodeScanning` with `Barcode.FORMAT_QR_CODE`.
|
||||||
|
- Thread-safe feed to `BalDecoder` (analyzer runs on a background executor); UI status
|
||||||
|
via `runOnUiThread`.
|
||||||
|
- Header row: format chip + `received/total`; on `done` -> stop analyzer -> launch
|
||||||
|
`ResultActivity` (results as Parcelable); "New scan" restarts.
|
||||||
|
- Garbage / incomplete frames silently ignored (same policy as the plugin); a
|
||||||
|
`TransferConflictError` mid-scan resets the session and signals the user to rescan.
|
||||||
|
|
||||||
|
### 5.3 `ResultActivity` (viewer)
|
||||||
|
|
||||||
|
- `kind == "will"`: whole-will JSON - each item's `tx` hex shown truncated with full view
|
||||||
|
on demand.
|
||||||
|
- `kind == "txs"`: list of tx hexes.
|
||||||
|
- Action bar: **Copy** (raw transfer text to clipboard), **Share** (ACTION_SEND
|
||||||
|
text/plain), **Save** (SAF `ACTION_CREATE_DOCUMENT` -> `will.json` / `will_tx.txt`).
|
||||||
|
- "Scan another" button -> finish -> back to camera.
|
||||||
|
|
||||||
|
### 5.4 `scripts/sync_codecs.py`
|
||||||
|
|
||||||
|
- Copies the 3 files from `bal/core/` -> `app/src/main/python/bal/core/` (idempotent).
|
||||||
|
- Post-copy check (dev machine): import `bal.core.animated_qr`, run one UR v2 frame +
|
||||||
|
decode cycle to prove the copy imports standalone.
|
||||||
|
- Documented as the rule after any codec change (README).
|
||||||
|
|
||||||
|
### 5.5 `test_chain/verify_chain.py`
|
||||||
|
|
||||||
|
Simulates the exact APK runtime path on the dev machine (no Android). Generates frames
|
||||||
|
precisely as the export page does, then feeds `AnimatedQrSession.add_part` in
|
||||||
|
scrambled/duplicated/dropped order and asserts correct results for:
|
||||||
|
|
||||||
|
- BAL QR multi-frame, plain **and** compressed (`Z` flag): `split_frames(encode_transfer(...))`.
|
||||||
|
- UR v1 single-part (`ur1_frames` headerless) and `1ofN` multipart.
|
||||||
|
- UR v2 single-part and fountain multipart with >=1 frame dropped and >=1 duplicated
|
||||||
|
(exercises the XOR solve).
|
||||||
|
- BBQR `Z`/`H`/`2`: `bbqr_frames(..., encoding=...)`, with the `Z` auto-decompress branch.
|
||||||
|
- payload kinds: whole-will JSON **and** tx-hex list.
|
||||||
|
- transfer-switch: feed a frame of a different transfer mid-session -> expect conflict, as
|
||||||
|
the UI will.
|
||||||
|
|
||||||
|
Runs under the runtime venv:
|
||||||
|
`source .../electrum/env/bin/activate && QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py`
|
||||||
|
|
||||||
|
## 6. Wire-format reference (bundled codecs)
|
||||||
|
|
||||||
|
- **BAL QR**: `BALQR1|<total>|<index>|<flags>|<payload>`, flags `""` or `Z` (zlib+base64).
|
||||||
|
Concatenate payloads 1..total -> transfer string -> `decode_transfer` splits on `\n`.
|
||||||
|
- **BC-UR v1**: multipart `ur:bytes/<seq>of<seq_len>/<sha256-bc32-digest>/<bc32-frag>`;
|
||||||
|
single-part `ur:bytes/<bc32>` (digest-less). BC32 = bech32_bis (XOR `0x3FFFFFFF`)
|
||||||
|
5-bit alphabet.
|
||||||
|
- **BC-UR v2**: multipart `ur:bytes/<seq>-of-<seq_len>/<bytewords-minimal-part>` +
|
||||||
|
single-part headerless; part body = CBOR `[seq, seq_len, msg_len, crc32, data]` +
|
||||||
|
per-part CRC-32, bytewords-minimal; fountain via `choose_fragments` (xoshiro256** +
|
||||||
|
alias/threshold), mixed by XOR - decoder solves from any sufficient subset.
|
||||||
|
- **BBQR**: `B$<encoding><type><base36 total><base36 index><payload>`; encodings `H`
|
||||||
|
(upper hex), `2` (base32nal), `Z` (deflate `wbits=-10` -> base32).
|
||||||
|
|
||||||
|
## 7. Toolchain & versions
|
||||||
|
|
||||||
|
| Item | Version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Chaquopy | 17.0.0 | Python 3.10-3.14, AGP 7.3-9.2, minSdk 24 |
|
||||||
|
| AGP | 8.10.0 | in Chaquopy 17 range |
|
||||||
|
| Gradle wrapper | 8.14 | required by AGP 8.10 |
|
||||||
|
| Kotlin | 2.0.21 | |
|
||||||
|
| compile/target SDK | 35 / min 24 | |
|
||||||
|
| JDK | 17 (machine has OpenJDK 17) | |
|
||||||
|
| CameraX | 1.3.4 | `camera-core`, `camera-camera2`, `camera-lifecycle`, `camera-view` |
|
||||||
|
| ML Kit barcode | 17.3.0 | on-device, no API key |
|
||||||
|
| Python (codec) | 3.12 (Chaquopy) | codecs verified 3.8-clean |
|
||||||
|
|
||||||
|
Dev machine: Android Studio + JDK 17 present; SDK platforms/build-tools/gradle absent ->
|
||||||
|
the **first real APK build happens in Android Studio with network**. `gradle-wrapper.jar`
|
||||||
|
is fetched via `curl` (canonical location) so `./gradlew` works; if network is blocked,
|
||||||
|
README documents Android Studio regenerating it.
|
||||||
|
|
||||||
|
## 8. Build & run outline (for README)
|
||||||
|
|
||||||
|
1. Open `android/` in Android Studio (or `./gradlew assembleDebug`).
|
||||||
|
2. Allow Gradle to fetch wrapper/deps (network).
|
||||||
|
3. Install on device; grant camera permission.
|
||||||
|
4. In the plugin: export dialog -> pick format (start with BAL QR default; also test
|
||||||
|
UR v1/UR v2/BBQR) -> show the animated QR on screen.
|
||||||
|
5. Point camera at screen; watch `received/total`; decoded result appears ->
|
||||||
|
Copy/Share/Save.
|
||||||
|
|
||||||
|
## 9. Verification
|
||||||
|
|
||||||
|
**On this machine (no Android needed)**
|
||||||
|
|
||||||
|
1. `python3 android/scripts/sync_codecs.py` -> copy + import/roundtrip sanity.
|
||||||
|
2. `QT_QPA_PLATFORM=offscreen python3 android/test_chain/verify_chain.py` -> all
|
||||||
|
formats/payload kinds/conflict cases pass.
|
||||||
|
3. `ruff check android/scripts/sync_codecs.py android/test_chain/verify_chain.py` -> clean.
|
||||||
|
4. `pytest tests/test_core_*.py tests/test_gui_*.py` -> still **445 passed**
|
||||||
|
(payload code untouched; only new files).
|
||||||
|
5. `python3 build_zip.py` unaffected (no change under `bal/`).
|
||||||
|
|
||||||
|
**On-device (manual, user)**
|
||||||
|
|
||||||
|
- Walk the 4 formats against the plugin's export page, single- and multi-frame
|
||||||
|
(animated), on a real phone.
|
||||||
|
- Confirm format chip, progress, auto-finish, Copy/Share/Save.
|
||||||
|
|
||||||
|
**Boundary** - no APK is produced by this machine's environment; the artifact is a
|
||||||
|
complete, independently buildable source tree + verified codec path.
|
||||||
|
|
||||||
|
## 10. Risks & notes
|
||||||
|
|
||||||
|
- First Gradle sync needs network (deps + wrapper). Pinned versions are conservative;
|
||||||
|
knobs documented.
|
||||||
|
- Bundled codec copies must be regenerated after any `bal/core/animated_qr.py` /
|
||||||
|
`qrtransfer.py` change - handled by `sync_codecs.py` + README note (copies are
|
||||||
|
committed for deterministic builds).
|
||||||
|
- Animated QR reading depends on the camera catching enough distinct frames (ML Kit
|
||||||
|
analyzer keeps scanning; the session dedups and accepts out-of-order). Slow phone
|
||||||
|
screens / glare may raise time-to-complete - expected, same as the plugin.
|
||||||
|
- App name/package (`life.after.bitcoin`, label "BAL Reader") are placeholders - trivial
|
||||||
|
to change.
|
||||||
|
- MIT: bundled codec files inherit the plugin's MIT license (noted in README).
|
||||||
125
AGENTS.md
Normal file
125
AGENTS.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
BAL — Bitcoin After Life, an Electrum plugin (inheritance / dead-man's-switch).
|
||||||
|
Source-of-truth docs: `README.md`, `HANDOFF.md`, `COMPATIBILITY.md`.
|
||||||
|
|
||||||
|
Paths below are relative to the repo, or use `$BAL_HOME` (the directory
|
||||||
|
containing this repo and the sibling `electrum/` checkout).
|
||||||
|
|
||||||
|
## Environments (critical)
|
||||||
|
|
||||||
|
Two separate venvs; using the wrong one is the #1 mistake.
|
||||||
|
|
||||||
|
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
|
||||||
|
`source "$BAL_HOME/electrum/env/bin/activate"`
|
||||||
|
This is an editable install of the Electrum 4.8.0 checkout at
|
||||||
|
`$BAL_HOME/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 work **both** as standalone scripts and via pytest (tests use `def test_*`
|
||||||
|
naming and also have `if __name__ == "__main__"` blocks). Run a single file
|
||||||
|
directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/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
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run a batch with pytest (as `make-release.sh` does):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 -m pytest tests/test_core_*.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
- 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). Per-file ignores suppress `F403`/`F405` for the intentional
|
||||||
|
`from .common import *` hub pattern in `bal/gui/qt/`.
|
||||||
|
- Lint via the repo venv: `./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`, `checkalive.py`, `reminders.py`,
|
||||||
|
`input_rules.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.
|
||||||
|
`common.py` uses `import *` intentionally (ruff suppresses F403/F405 here);
|
||||||
|
`bal/gui/qt/*.py` all import from it.
|
||||||
|
- `bal/cli/` = headless command-line layer (no Qt). `plugin.py` is the daemon
|
||||||
|
entry point, `commands.py` registers `bal_*` commands with Electrum.
|
||||||
|
- `bal/wallet_util/` = wallet helper utilities for Qt and core.
|
||||||
|
- `bal/qt.py` and `bal/cmdline.py` are thin shims that Electrum discovers
|
||||||
|
via `manifest.json`; they import the real `Plugin` class via `importlib`.
|
||||||
|
- `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`).
|
||||||
|
|
||||||
|
## Translations (i18n)
|
||||||
|
|
||||||
|
How it works: `bal/i18n.py` (Electrum's catalog first, then BAL's, then
|
||||||
|
English). Plan and decisions: `PLAN_I18N.md`.
|
||||||
|
|
||||||
|
- Sources in git: `babel.cfg`, `bal/locale/bal.pot` (template) and
|
||||||
|
`bal/locale/<lang>/LC_MESSAGES/bal.po` (one per language). The compiled
|
||||||
|
`.mo` files are NOT in git: `build_zip.py` builds them into the zip.
|
||||||
|
- Writing texts: `_("... {}").format(x)`, never f-strings or `%` inside
|
||||||
|
`_()` (Ruff INT); `N_()` for class attributes/constants, translated with
|
||||||
|
`_()` when shown. Never translate stored or compared text (wallet labels,
|
||||||
|
`HISTORY_LABEL`, the status history).
|
||||||
|
- After changing texts, refresh the catalogs (then translate the new
|
||||||
|
entries, e.g. with Poedit):
|
||||||
|
```bash
|
||||||
|
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||||
|
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap
|
||||||
|
```
|
||||||
|
- New language: `pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l <lang>`
|
||||||
|
(use Electrum's language code, e.g. `de_DE`).
|
||||||
|
- Dev install (symlink, not the zip): compile once to see the translations,
|
||||||
|
`pybabel compile -d bal/locale -D bal`.
|
||||||
|
- `tests/test_translations.py` checks every catalog ({} fields, `$tokens`,
|
||||||
|
no addresses/links/e-mails added by a translation) and prints a summary
|
||||||
|
when run standalone.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
- `build_zip.py` needs **Babel** (`pip install babel`): it compiles the
|
||||||
|
translation catalogs into the zip and stops if Babel is missing.
|
||||||
|
- `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).
|
||||||
170
AUDIO_MODEM_DEBIAN.md
Normal file
170
AUDIO_MODEM_DEBIAN.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Audio MODEM on Debian — setup & troubleshooting
|
||||||
|
|
||||||
|
How to make the optional **audio channel** of BAL (and Electrum's own
|
||||||
|
`audio_modem` plugin) work on Debian/Ubuntu. The channel lets you send a will
|
||||||
|
to another device as acoustic OFDM tones instead of scanning QR codes.
|
||||||
|
|
||||||
|
Recommended reading before starting: `CHANGELOG.md` entry 56
|
||||||
|
(Audio-environment notes) and `HANDOFF.md` (Dev-box audio prerequisites).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What you need (three independent pieces)
|
||||||
|
|
||||||
|
| Piece | Provides | Where it comes from |
|
||||||
|
|--------------------------|--------------------------------------------|--------------------------------------|
|
||||||
|
| `amodem` (Python) | OFDM modulation/demodulation | `pip install amodem` (any venv) |
|
||||||
|
| `libportaudio.so` | sound I/O backend used by `amodem.audio` | Debian package `libportaudio2` (+ dev symlink, see §2) |
|
||||||
|
| Electrum `audio_modem` | the plugin whose `_send`/`_recv` BAL reuses | built into Electrum |
|
||||||
|
|
||||||
|
BAL shows the audio buttons only when the plugin is **enabled** (Tools →
|
||||||
|
Plugins → Audio Modem) and `amodem` is importable.
|
||||||
|
|
||||||
|
> On a headless/CI box there is no speaker/mic, but the channel can still be
|
||||||
|
> verified with the **sink-monitor loopback** in §4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The two line fixes (this is the part everyone forgets)
|
||||||
|
|
||||||
|
Debian ships a versioned `libportaudio.so.2` but **not** the unversioned
|
||||||
|
`libportaudio.so` that old `amodem` code uses, and `amodem` uses NumPy APIs
|
||||||
|
removed in NumPy 2.x. Both fail **silently** (the plugin's `_send` runs the
|
||||||
|
load inside a `WaitingDialog` thread without an `on_error` handler).
|
||||||
|
|
||||||
|
### 2a. PortAudio unversioned symlink
|
||||||
|
|
||||||
|
Install the dev package (creates the unversioned symlink), or create it by
|
||||||
|
hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install libportaudio2 libportaudio-dev # preferred
|
||||||
|
# or, without the package:
|
||||||
|
sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 \
|
||||||
|
/usr/lib/x86_64-linux-gnu/libportaudio.so
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 -c "import amodem.audio; print(amodem.audio.Interface(config=None).load('libportaudio.so').call('GetVersionText'))"
|
||||||
|
# b'PortAudio V19...' <-- success
|
||||||
|
```
|
||||||
|
|
||||||
|
> **No-sudo alternative** (fine for one-shot tests): point `LD_LIBRARY_PATH`
|
||||||
|
> at a directory containing a `libportaudio.so` symlink to the `.so.2`:
|
||||||
|
> ```bash
|
||||||
|
> mkdir -p /tmp/portaudio_stub
|
||||||
|
> ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||||
|
> export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 2b. amodem vs NumPy 2.x (`tostring` removed)
|
||||||
|
|
||||||
|
`amodem` 1.16.0 calls `numpy.ndarray.tostring()`, removed in NumPy 2.x
|
||||||
|
(≥ 2.4.6 dies with `AttributeError` on the first sample write, so **no carrier
|
||||||
|
is ever emitted**). Either pin NumPy < 2, or patch the single line in the
|
||||||
|
installed package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 -m pip install "numpy<2" # option A (downgrade)
|
||||||
|
# option B (patch; path depends on your site-packages):
|
||||||
|
sed -i "s/sym.astype('int16').tostring()/sym.astype('int16').tobytes()/" \
|
||||||
|
"$BAL_HOME/electrum/env/lib/python3.11/site-packages/amodem/common.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
> This must be done on **every** machine that receives/sends audio (both ends
|
||||||
|
> of the channel use the same code), and again after reinstalling/upgrading
|
||||||
|
> `amodem`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Environment checklist (dev box, already applied)
|
||||||
|
|
||||||
|
These were applied on the current dev box and do NOT need to be re-done:
|
||||||
|
|
||||||
|
- `amodem` installed in the runtime venv (`1.16.0`).
|
||||||
|
- `amodem/common.py` patched `tostring()` → `tobytes()`.
|
||||||
|
- System symlink or `LD_LIBRARY_PATH` stub for `libportaudio.so`.
|
||||||
|
- PulseAudio running; default sink `ALC236 Analog`, default source DMIC.
|
||||||
|
|
||||||
|
Check them in one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import amodem, ctypes, numpy, zlib
|
||||||
|
print("amodem", amodem.__version__)
|
||||||
|
print("numpy", numpy.__version__, "(2.x needs the tobytes patch)")
|
||||||
|
import amodem.audio
|
||||||
|
amodem.audio.Interface(config=None).load("libportaudio.so")
|
||||||
|
print("libportaudio.so loaded OK (symlink or LD_LIBRARY_PATH in place)")
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verifying the channel (no speakers/mic needed)
|
||||||
|
|
||||||
|
Full **send → sink → sink-monitor → recv** round-trip on one machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) route capture at the loop and remember the original source
|
||||||
|
MON="$(pactl get-default-sink).monitor"; ORIG=$(pactl get-default-source)
|
||||||
|
pactl set-default-source "$MON"
|
||||||
|
|
||||||
|
# 2) run the round-trip (uses zlib-compressed payload like the plugin)
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
timeout 90 python3 /tmp/opencode/bal_audio_loopback.py
|
||||||
|
# expected: bitrate 1.0 kbps ... send done ... RECV OK
|
||||||
|
|
||||||
|
# 3) restore the original source
|
||||||
|
pactl set-default-source "$ORIG"
|
||||||
|
```
|
||||||
|
|
||||||
|
Any payload you like: `python3 /tmp/opencode/bal_audio_loopback.py "BALQR|1|1|0|hi"`.
|
||||||
|
|
||||||
|
With real speakers + mic instead, skip the `pactl` swapping, put the devices
|
||||||
|
close, keep volumes high, and run the same script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing through the real GUI
|
||||||
|
|
||||||
|
1. **Tools → (Plugins) → Audio Modem** → enable it. If asked for settings,
|
||||||
|
pick a bitrate: default `slowest()` is ~1.0–1.2 kbps (a ~2 KB will takes
|
||||||
|
~15–20 s of audio); higher bitrates are faster but less robust.
|
||||||
|
2. Wallet A → BAL will list → **Export → QR Codes → Audio…**
|
||||||
|
(the audio transport sends the raw newline-joined tx list, no BAL framing).
|
||||||
|
3. Wallet B → will list → **Import via QR → Audio…** → wait for
|
||||||
|
"Waiting for audio (... kbps)…", a loading cursor while demodulating,
|
||||||
|
then the decoded slots appear → review/sign wizard opens.
|
||||||
|
4. One machine only: apply the §4 monitor trick in the shell where Electrum
|
||||||
|
runs (export plays to the sink; import records from the sink monitor).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| No sound at all, no error anywhere in the log | `libportaudio.so` not loadable (silent) | §2a symlink or `LD_LIBRARY_PATH` stub |
|
||||||
|
| Sound played, "Timeout waiting for carrier" on the receive end | Capture routed to the wrong device / mic muted / no speakers | §4 monitor trick; `pactl` source check; raise volume; move devices closer |
|
||||||
|
| "Decoding failed" after carrier, no payload | Send side died with numpy `tostring` → nothing modulated | §2b patch or `numpy<2` on BOTH machines |
|
||||||
|
| Buttons "Audio…" missing in BAL dialogs | `audio_modem` disabled in Plugins, or `amodem` not importable in the running venv | Enable plugin; `pip install amodem` |
|
||||||
|
| Audio too long / too slow | 1 kbps default | Raise bitrate in Audio Modem settings dialog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. No-sudo quick reference (all commands)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install amodem
|
||||||
|
mkdir -p /tmp/portaudio_stub
|
||||||
|
ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /tmp/portaudio_stub/libportaudio.so
|
||||||
|
export LD_LIBRARY_PATH=/tmp/portaudio_stub:$LD_LIBRARY_PATH
|
||||||
|
# numpy >= 2 (one of):
|
||||||
|
pip install "numpy<2" # or patch amodem/common.py tobytes
|
||||||
|
```
|
||||||
1220
CHANGELOG.md
1220
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
42
COMPATIBILITY.md
Normal file
42
COMPATIBILITY.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## QR wire-format compatibility
|
||||||
|
|
||||||
|
BAL exports/imports wills as QR codes using **BBQR**:
|
||||||
|
|
||||||
|
| Format | Wire appearance | Interop target |
|
||||||
|
|-----------|----------------------------|------------------------------------------------------|
|
||||||
|
| BBQR | `B$<enc><type><N><n>…` | Coinkite BitKit / Coldcard's BBQR animated-QR mode |
|
||||||
|
|
||||||
|
Import auto-detects the format of each scanned code; out-of-order and duplicate
|
||||||
|
BBQR frames are handled. Interop has not yet been cross-verified against
|
||||||
|
third-party libraries (`bbqr`, Coldcard firmwares).
|
||||||
|
|
||||||
|
## Reporting compatibility issues
|
||||||
|
|
||||||
|
If you find a compatibility problem not listed here, please open an issue on
|
||||||
|
this repository describing the wallet type, Electrum version, and the exact
|
||||||
|
error or unexpected behavior observed.
|
||||||
99
COMPATIBILITY_ROADMAP.md
Normal file
99
COMPATIBILITY_ROADMAP.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Compatibility Roadmap: Multisig and TrustedCoin (2FA) Wallets
|
||||||
|
|
||||||
|
Status document, 2026-07-19. Companion to [`COMPATIBILITY.md`](COMPATIBILITY.md):
|
||||||
|
that file states *what* is supported today; this one explains *why* multisig and
|
||||||
|
TrustedCoin (2FA) wallets are currently unsupported and *how* support can be
|
||||||
|
added.
|
||||||
|
|
||||||
|
## Root cause (common to both)
|
||||||
|
|
||||||
|
BAL currently does two things that are only valid for standard
|
||||||
|
(single-signature) wallets:
|
||||||
|
|
||||||
|
1. **It builds transactions itself** with `PartialTransaction.from_io(...)`
|
||||||
|
(`bal/core/will.py`), bypassing `wallet.make_unsigned_transaction`.
|
||||||
|
2. **It signs with a single call** — `wallet.sign_transaction(tx, password)`
|
||||||
|
(`bal/gui/qt/window.py`, `sign_transactions`) — and considers the will ready
|
||||||
|
when `tx.is_complete()` is true.
|
||||||
|
|
||||||
|
On a standard wallet one signature completes the transaction. On multisig and
|
||||||
|
2FA wallets **one signature is not enough**: the transaction stays incomplete,
|
||||||
|
is never marked `COMPLETE`, and can never be pushed to will-executors.
|
||||||
|
|
||||||
|
A secondary single-sig assumption: `plugin.py` uses `wallet.get_keystore()`
|
||||||
|
(singular); multisig wallets expose `get_keystores()` (plural).
|
||||||
|
|
||||||
|
## Multisig wallets — solvable, medium/large effort
|
||||||
|
|
||||||
|
A 2-of-3 multisig wallet typically holds **only one** of the required private
|
||||||
|
keys locally; the other cosigners hold theirs. `wallet.sign_transaction` adds
|
||||||
|
the local signature only, and BAL has no flow to collect the missing ones.
|
||||||
|
|
||||||
|
**Proposed solution: the standard PSBT coordination round** (the same flow
|
||||||
|
Electrum itself uses for multisig spending):
|
||||||
|
|
||||||
|
1. Build and sign locally as today.
|
||||||
|
2. If the transaction is not complete, **export the partially-signed
|
||||||
|
transaction(s)** (file and/or QR) and mark the will with a new status such
|
||||||
|
as `WAITING_COSIGNERS`.
|
||||||
|
3. Each cosigner signs in their own Electrum (native feature — no new
|
||||||
|
software needed on their side).
|
||||||
|
4. BAL **re-imports and merges the signatures**; once complete, the will is
|
||||||
|
pushed to will-executors as today.
|
||||||
|
|
||||||
|
Notes and caveats:
|
||||||
|
|
||||||
|
- **Chained will transactions** (a will tx spending the change of a previous
|
||||||
|
will tx) remain workable: with segwit, the txid of an unsigned/partially
|
||||||
|
signed transaction is already stable, so the whole chain can be exported as
|
||||||
|
a batch of PSBTs in one round.
|
||||||
|
- **Every rebuild requires a new cosigner round.** Check Alive postponements
|
||||||
|
and balance-change rebuilds re-sign the will, so each of them needs the
|
||||||
|
cosigners again. This is inherent to multisig and must be clearly
|
||||||
|
communicated in the UI.
|
||||||
|
- Implementation surface: export/import/merge pipeline, GUI for it, the new
|
||||||
|
status in the transaction list, and tests.
|
||||||
|
|
||||||
|
Target: **next plugin release**, as announced.
|
||||||
|
|
||||||
|
## TrustedCoin (2FA) wallets — harder, with one blocking unknown
|
||||||
|
|
||||||
|
An Electrum 2FA wallet (`Wallet_2fa`, defined in Electrum's `trustedcoin`
|
||||||
|
plugin) is technically a **2-of-3 multisig whose second signer is the
|
||||||
|
TrustedCoin server**:
|
||||||
|
|
||||||
|
- signing requires a **one-time password (OTP) per transaction**
|
||||||
|
(`server.sign(short_id, raw_tx, otp)`);
|
||||||
|
- the server co-signs only transactions that include **its billing fee**,
|
||||||
|
which Electrum adds inside `Wallet_2fa.make_unsigned_transaction` — a code
|
||||||
|
path BAL currently bypasses (see root cause #1).
|
||||||
|
|
||||||
|
So today: no billing output, no OTP prompt, local signature only → incomplete
|
||||||
|
transaction.
|
||||||
|
|
||||||
|
Even with full integration (building via the wallet's
|
||||||
|
`make_unsigned_transaction`, adding the OTP prompt flow), one **decisive
|
||||||
|
unknown** remains: will transactions carry a **locktime years in the future**.
|
||||||
|
Whether the TrustedCoin server agrees to co-sign a transaction with such a
|
||||||
|
far-future `nLockTime` is an undocumented server-side policy. If it refuses,
|
||||||
|
2FA support is **not achievable** without TrustedCoin's cooperation. This is
|
||||||
|
why `COMPATIBILITY.md` marks 2FA as *unknown*.
|
||||||
|
|
||||||
|
**Proposed plan:**
|
||||||
|
|
||||||
|
1. **Empirical test on testnet** (cheap, decisive): create a test 2FA wallet,
|
||||||
|
build a far-future-locktime transaction through the proper 2FA path, and
|
||||||
|
check whether the server signs it.
|
||||||
|
2. If it signs → implement support: build via `make_unsigned_transaction`
|
||||||
|
(billing output included), integrate the OTP prompt, and document that
|
||||||
|
every rebuild costs one OTP round and TrustedCoin fees.
|
||||||
|
3. If it refuses → document 2FA as unsupported, with the practical
|
||||||
|
workaround: Electrum allows disabling 2FA by restoring the wallet from the
|
||||||
|
full seed, which turns it into a standard wallet — fully supported by BAL.
|
||||||
|
|
||||||
|
## Recommended order of work
|
||||||
|
|
||||||
|
1. **Multisig first**: deterministic path, standard Electrum tooling, already
|
||||||
|
announced for the next release.
|
||||||
|
2. **TrustedCoin empirical test in parallel**: low cost, and its outcome
|
||||||
|
decides whether 2FA support is feasible at all.
|
||||||
255
HANDOFF.md
255
HANDOFF.md
@@ -10,7 +10,7 @@
|
|||||||
## 0. TL;DR — what this project is
|
## 0. TL;DR — what this project is
|
||||||
|
|
||||||
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
|
- **Product:** BAL ("Bitcoin After Life") — an inheritance plugin for the
|
||||||
**Electrum 4.7.2** Bitcoin wallet (Qt / **PyQt6**).
|
**Electrum 4.7.2 and 4.8.0** Bitcoin wallet (Qt / **PyQt6**).
|
||||||
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user
|
- **Form:** external **ZIP plugin** (not bundled in Electrum). The user
|
||||||
installs the ZIP from Electrum's plugin manager.
|
installs the ZIP from Electrum's plugin manager.
|
||||||
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
|
- **What it does:** lets a wallet owner pre-build, sign and (later) broadcast
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
services) can be paid a fee to broadcast the inheritance when due. The owner
|
services) can be paid a fee to broadcast the inheritance when due. The owner
|
||||||
periodically proves they are alive ("check-alive"); if the deadline passes,
|
periodically proves they are alive ("check-alive"); if the deadline passes,
|
||||||
the inheritance becomes spendable.
|
the inheritance becomes spendable.
|
||||||
- **Current version:** see `bal/VERSION` (last shipped: **0.4.8**).
|
- **Current version:** see the `"version"` field of `bal/manifest.json` (the single source of truth; read at runtime via `get_version()` in `bal/core/plugin_base.py`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,29 +55,42 @@ These are non-negotiable. They come from the owner directly.
|
|||||||
|
|
||||||
```
|
```
|
||||||
bal/ <- the plugin package (this is what ships in the ZIP)
|
bal/ <- the plugin package (this is what ships in the ZIP)
|
||||||
__init__.py <- __version__ (one of 4 version files)
|
__init__.py <- package docstring (no version here anymore)
|
||||||
VERSION <- plain-text version (one of 4 version files)
|
manifest.json <- plugin manifest, "version" field (SINGLE SOURCE OF TRUTH for the version)
|
||||||
manifest.json <- plugin manifest, "version" field (one of 4)
|
qt.py <- zipimport shim used when loaded as an external ZIP plugin
|
||||||
|
cmdline.py <- CLI entry-point shim (Electrum gui_name='cmdline')
|
||||||
core/
|
core/
|
||||||
plugin_base.py <- __version__ "AUTOMATICALLY GENERATED" (one of 4)
|
plugin_base.py <- get_version() reads the version from manifest.json (zip-safe)
|
||||||
heirs.py <- HEIRS + transaction building (prepare_lists,
|
heirs.py <- HEIRS + transaction building (prepare_lists,
|
||||||
prepare_transactions, buildTransactions). CORE LOGIC.
|
prepare_transactions, buildTransactions). CORE LOGIC.
|
||||||
will.py <- Will/WillItem, validation (check_amounts, check_will),
|
will.py <- Will/WillItem, validation (check_amounts, check_will),
|
||||||
exceptions (AmountException, WillExpiredException, ...).
|
exceptions (AmountException, WillExpiredException, ...).
|
||||||
willexecutors.py <- remote will-executor services handling.
|
willexecutors.py <- remote will-executor services handling (is_selected / is_valid,
|
||||||
|
parallel push/check).
|
||||||
util.py <- locktime parsing/most helpers (timestamps only).
|
util.py <- locktime parsing/most helpers (timestamps only).
|
||||||
|
checkalive.py <- resolve_date_to_check, resolve_relative_check_alive_date,
|
||||||
|
ensure_check_alive_valid (GUI-free; signed relative rule).
|
||||||
|
reminders.py <- compute_reminder_offsets, BALCalendar .ics generation (GUI-free).
|
||||||
|
input_rules.py <- locktime/threshold data models, Raw/Date selector logic (GUI-free).
|
||||||
|
cli/ <- headless command-line layer (no Qt)
|
||||||
|
__init__.py <- registers bal_* commands on import
|
||||||
|
commands.py <- bal_* daemon commands (@plugin_command, async, thin transport)
|
||||||
|
controller.py <- BalController: headless replica of BalWindow (no Qt)
|
||||||
|
plugin.py <- CLI Plugin entry point (extends BalPlugin, no Qt hooks)
|
||||||
gui/qt/
|
gui/qt/
|
||||||
common.py <- shared imports; every gui module does
|
common.py <- shared imports; every gui module does
|
||||||
`from .common import *`. Add new shared imports HERE.
|
`from .common import *`. Add new shared imports HERE.
|
||||||
dialogs.py <- the big build/sign/broadcast dialog
|
dialogs.py <- the big build/sign/broadcast dialog
|
||||||
(BalBuildWillDialog, task_phase1/2), wizard glue.
|
(BalBuildWillDialog, task_phase1/2), wizard glue.
|
||||||
widgets.py <- WillSettingsWidget + wizard widgets/labels.
|
widgets.py <- WillSettingsWidget + wizard widgets/labels.
|
||||||
window.py <- BalWalletWindow (build_will, check_will, get_transactions).
|
window.py <- BalWindow, the per-wallet controller (build_will, check_will,
|
||||||
lists.py, calendar.py, theme.py, window_utils.py, ...
|
get_transactions, merge_will, on_close, menubar wiring).
|
||||||
tests/ <- pytest suite (see run command below).
|
plugin.py <- Electrum @hooks entry point (init_qt, tools menu, settings dialog).
|
||||||
electrum-src/ <- a copy of Electrum source, used ONLY for tests
|
lists.py, calendar.py, theme.py (status colours), window_utils.py
|
||||||
(PYTHONPATH=electrum-src). NOT shipped in the ZIP.
|
wallet_util/ <- standalone wallet-inspection helpers, no Qt
|
||||||
build_zip.py <- builds the shippable ZIP (37 files).
|
tests/ <- standalone test scripts (see Section 3).
|
||||||
|
docs/ <- user manual + inheritance-options guide (.md sources).
|
||||||
|
build_zip.py <- builds the shippable ZIP (36 files).
|
||||||
CHANGELOG.md <- numbered task log (English).
|
CHANGELOG.md <- numbered task log (English).
|
||||||
.agent_memory_tasks.md <- terse internal memory notes per task batch.
|
.agent_memory_tasks.md <- terse internal memory notes per task batch.
|
||||||
HANDOFF.md <- this file.
|
HANDOFF.md <- this file.
|
||||||
@@ -87,46 +100,73 @@ HANDOFF.md <- this file.
|
|||||||
|
|
||||||
## 3. How to build, test and lint
|
## 3. How to build, test and lint
|
||||||
|
|
||||||
Run everything from `/home/user/webapp`.
|
Two separate venvs — using the wrong one is the #1 mistake:
|
||||||
|
|
||||||
|
- **Runtime env** (Electrum + PyQt6, has `electrum` importable):
|
||||||
|
`source "$BAL_HOME/electrum/env/bin/activate"` — an editable
|
||||||
|
install of the Electrum **4.8.0** checkout at
|
||||||
|
`$BAL_HOME/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 the repo root (the checkout directory; set
|
||||||
|
`BAL_HOME` to its parent to use `$BAL_HOME/electrum`).
|
||||||
|
|
||||||
|
**Tests are standalone scripts (not pytest):** each `tests/test_*.py` runs its
|
||||||
|
`test_*` functions from `if __name__ == "__main__"`. Run a file directly:
|
||||||
|
|
||||||
**Full test suite (expected: 266 passed as of v0.4.8):**
|
|
||||||
```bash
|
```bash
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
tests/test_core_*.py tests/test_gui_*.py \
|
python3 tests/test_core_heirs.py # core, no Qt needed
|
||||||
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py # GUI tests need offscreen
|
||||||
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
|
|
||||||
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
|
||||||
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
|
||||||
tests/test_group_h_v048.py -q
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Most core tests run offline (no wallet/network). Some files
|
||||||
|
(`tests/test_group_*.py`, `tests/test_no_willexecutor_karen7.py`,
|
||||||
|
`parallel_ping_test.py`) exercise will-executor/network flows and need the live
|
||||||
|
servers — don't rely on them for quick verification.
|
||||||
|
|
||||||
|
**Current state of the suite: 427 tests collected.** The offline subset passes
|
||||||
|
(414 passed) apart from pre-existing failures that are NOT yours to fix without
|
||||||
|
asking: 13 failures in `tests/test_core_will_invalidate.py` (a `None` fee when a
|
||||||
|
UTXO has no fee value, `bal/core/will.py:482`) and 1 collection error in
|
||||||
|
`tests/test_group_i_basic_checkalive.py` (missing
|
||||||
|
`BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS`).
|
||||||
|
|
||||||
**Lint (only NEW errors matter; ignore pre-existing noise):**
|
**Lint (only NEW errors matter; ignore pre-existing noise):**
|
||||||
```bash
|
```bash
|
||||||
ruff check <files> | grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
./venv/bin/ruff check <files> \
|
||||||
| grep -vE "F401|F403|F405|F841"
|
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" | grep -vE "F401|F403|F405|F841"
|
||||||
```
|
```
|
||||||
Pre-existing, KNOWN-OK ruff noise: `F401/F403/F405` (star-imports via
|
Ruff is NOT clean repo-wide (hundreds of pre-existing errors in `bal/` and
|
||||||
|
`tests/`); do NOT run `--fix` wholesale — just avoid adding new violations.
|
||||||
|
Pre-existing, KNOWN-OK noise: `F401/F403/F405` (star-imports via
|
||||||
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
|
`from .common import *`) and 2× `F841` (an unused `e` in two `except` blocks).
|
||||||
Do NOT "fix" these unless asked — they are intentional / out of scope.
|
Do NOT "fix" these unless asked — they are intentional / out of scope.
|
||||||
|
|
||||||
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
|
**Build the ZIP (always clear caches first so zipimport doesn't ship stale .pyc):**
|
||||||
```bash
|
```bash
|
||||||
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
|
find bal -name "__pycache__" -type d -exec rm -rf {} + ; find bal -name "*.pyc" -delete
|
||||||
python3 build_zip.py bal-electrum-plugin-vX.Y.Z.zip # produces 37 files
|
python3 build_zip.py # -> bal-electrum-plugin.zip (deterministic, prints sha256; 36 files)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bump version — there are FOUR files, keep them in sync:**
|
**Bump version — ONE file only (single source of truth):**
|
||||||
```
|
```
|
||||||
bal/core/plugin_base.py -> __version__ = "X.Y.Z" # AUTOMATICALLY GENERATED DO NOT EDIT
|
|
||||||
bal/__init__.py -> __version__ = "X.Y.Z"
|
|
||||||
bal/VERSION -> X.Y.Z
|
|
||||||
bal/manifest.json -> "version": "X.Y.Z",
|
bal/manifest.json -> "version": "X.Y.Z",
|
||||||
```
|
```
|
||||||
|
The code reads this at runtime via `get_version()` in `bal/core/plugin_base.py` (exposed as the `BalPlugin.version` property), so there is nothing else to keep in sync. There is no longer a `bal/VERSION` file nor a hardcoded `__version__`.
|
||||||
|
|
||||||
**IMPORTANT for the owner when testing:** after installing a ZIP, the owner
|
**IMPORTANT for the owner when testing:** after installing a ZIP, the owner
|
||||||
must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
||||||
`zipimport` caches modules, so a partial reload runs stale code.
|
`zipimport` caches modules, so a partial reload runs stale code.
|
||||||
|
|
||||||
|
**Automated release:** use `./make-release.sh` to run the full release flow
|
||||||
|
(tests, lint, build, GPG sign, SHA-256, Electrum test pause, Gitea release).
|
||||||
|
See Section 5 for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Key technical knowledge (hard-won — saves you hours)
|
## 4. Key technical knowledge (hard-won — saves you hours)
|
||||||
@@ -225,25 +265,49 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
|||||||
|
|
||||||
## 5. Git / delivery workflow
|
## 5. Git / delivery workflow
|
||||||
|
|
||||||
- **Branch:** work on `genspark_ai_developer`. Open PRs into `main`.
|
- **Branch:** work directly on `main` (no PR flow anymore). Push straight to
|
||||||
|
`origin/main` (Gitea).
|
||||||
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
|
- **Commit policy:** ZIP-FIRST — build a test ZIP, let the owner confirm it
|
||||||
works, THEN commit. (This differs from "commit after every change"; the owner
|
works, THEN commit. (This differs from "commit after every change"; the owner
|
||||||
explicitly prefers ZIP-first because they manually test each build.)
|
explicitly prefers ZIP-first because they manually test each build.)
|
||||||
- Before opening/updating a PR: `git fetch origin main`, rebase, resolve
|
- Before pushing: check `git status`/`git diff`, stage only the intended files
|
||||||
conflicts preferring remote `main` unless a local change is essential,
|
(never secrets), commit with a concise message, then push to `origin/main`.
|
||||||
squash local commits into ONE comprehensive commit, push (force if needed),
|
|
||||||
then create/update the PR and SHARE the PR URL with the owner.
|
|
||||||
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
|
- **ZIPs are NOT committed** (`.gitignore` excludes `*.zip`). They are
|
||||||
distributed via **GitHub Releases** (`gh release create vX.Y.Z file.zip ...`).
|
distributed via **Gitea Releases** using `make-release.sh`.
|
||||||
The newest release is the "Latest" and is the owner's convenient download.
|
- **Release process** (`make-release.sh`):
|
||||||
- Deliverable ZIPs are ALSO uploaded with the file-wrapper tool so the owner can
|
1. Version bump in `bal/manifest.json` (single source of truth)
|
||||||
download them directly from chat.
|
2. Clean `__pycache__` and `.pyc` files
|
||||||
- **Auth note:** if `git push` / `gh` fails with "Invalid username or token",
|
3. Run full test suite
|
||||||
re-run the GitHub environment setup, then retry.
|
4. Lint with ruff (skip if not installed)
|
||||||
- PR history for this line of work: **#13** (v0.4.7), **#14** (docs/DUST section +
|
5. Build ZIP via `build_zip.py` (deterministic order, SHA-256, manifest check)
|
||||||
translation), **#15** (v0.4.8). All merged into `main`.
|
6. GPG sign: `.asc` (armor) + `.sig` (binary) with key `A847D004DB91610711CA6A0DFE756706E833E0D1`
|
||||||
- Releases: latest is **v0.4.8** (asset `bal-electrum-plugin-v0.4.8.zip`);
|
7. Export public key as `svatantrya.asc`
|
||||||
v0.4.7 kept in history.
|
8. SHA-256 checksum
|
||||||
|
9. Interactive pause for Electrum testing (ZIP-FIRST policy)
|
||||||
|
10. Create Gitea tag, push, create release, upload 5 assets (ZIP + .asc + .sig + .sha256 + svatantrya.asc)
|
||||||
|
- **Usage:**
|
||||||
|
```bash
|
||||||
|
./make-release.sh # read version from bal/manifest.json
|
||||||
|
./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||||
|
```
|
||||||
|
- **Release assets** (5 files):
|
||||||
|
- `bal_vX.Y.Z.zip` — the plugin
|
||||||
|
- `bal_vX.Y.Z.zip.asc` — GPG signature (armor)
|
||||||
|
- `bal_vX.Y.Z.zip.sig` — GPG signature (binary)
|
||||||
|
- `bal_vX.Y.Z.zip.sha256` — SHA-256 checksum
|
||||||
|
- `svatantrya.asc` — signing public key
|
||||||
|
- **GPG verification instructions** (included in release body):
|
||||||
|
```bash
|
||||||
|
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
|
||||||
|
gpg --verify bal_vX.Y.Z.zip.asc bal_vX.Y.Z.zip
|
||||||
|
```
|
||||||
|
- **Auth note:** if `git push` or Gitea API fails with "invalid credentials",
|
||||||
|
update `GITEA_TOKEN` env var or `~/.git-credentials`, then retry.
|
||||||
|
- Older PR history (pre-`main` direct workflow): **#13** (v0.4.7), **#14**
|
||||||
|
(docs/DUST section + translation), **#15** (v0.4.8), **#4** (v0.6.1 —
|
||||||
|
manifest.json version); all merged into `main`.
|
||||||
|
- Releases: latest is **v0.7.0**; v0.6.1, v0.6.0 and v0.5.18 before it; the older
|
||||||
|
v0.2.x line is kept in history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,6 +336,31 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
|||||||
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
|
(#7b) "Balance is too low… Skipped" recoloured ORANGE + space fix; Reset button
|
||||||
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
|
renamed "Reset to Default Setting". 8 new tests (`test_group_h_v048.py`).
|
||||||
266 tests pass.
|
266 tests pass.
|
||||||
|
- **v0.5.1 — v0.5.10** — BASIC/ADVANCED ("user type") mode work: Windows
|
||||||
|
settings-dialog flicker fix; Check Alive shown read-only in BASIC; BASIC
|
||||||
|
builds the will against "now" (`date_to_check = now()`, Check Alive fully
|
||||||
|
ignored); ADVANCED defaults to RAW (1y/30d); consistent Raw/Date default per
|
||||||
|
mode; Check Alive red-highlight fixes; clearer "could not build the will"
|
||||||
|
message; CHECK no longer resets a manual Date/RAW choice.
|
||||||
|
- **v0.5.11** — Electrum **4.8.0** compatibility (the `json_db.register_dict`
|
||||||
|
DB-registration API was removed in 4.8; the plugin now supports 4.7.2 and
|
||||||
|
4.8.0).
|
||||||
|
- **v0.5.12 — v0.5.18** — Check Alive soft-red highlight removed; short Tor
|
||||||
|
(.onion) will-executor URLs; KeyError fix on .onion executor actions; skip
|
||||||
|
.onion executors from download when Electrum is not on Tor; crash fix on a
|
||||||
|
non-dict welist response; clearer message when the list download
|
||||||
|
fails/times out over Tor.
|
||||||
|
- **v0.6.0** — version bump for the official repository release.
|
||||||
|
- **v0.6.1** — version read from `bal/manifest.json` (single source of truth);
|
||||||
|
`bal/VERSION` file removed.
|
||||||
|
- **#47 / #48 (post-v0.6.1)** — `is_selected`/`is_valid` fee bounds (extremes
|
||||||
|
allowed) and the `merge_will` missing-`date_to_check` crash fix (see
|
||||||
|
CHANGELOG).
|
||||||
|
- **v0.7.0** — OP_RETURN heirs; core extraction (checkalive, reminders,
|
||||||
|
input_rules); RLock pickle fix; `REBUILD_ON_CLOSE`; headless CLI layer
|
||||||
|
(`bal/cli/`, `bal/cmdline.py`, 30 `bal_*` commands); `AUTO_REBUILD` on new
|
||||||
|
transactions; `bal_will_autorebuild` CLI command; removed redundant
|
||||||
|
"Add transaction without willexecutor" from settings dialog.
|
||||||
|
|
||||||
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
|
### Open / suspended / backlog items (see `.agent_memory_tasks.md` for detail)
|
||||||
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
|
- **SUSPENDED — "(UTC)" label in the wizard.** The owner asked to show an
|
||||||
@@ -292,13 +381,77 @@ must **fully restart Electrum** (not just reload the plugin) — Electrum's
|
|||||||
## 7. How to resume (checklist for the next AI)
|
## 7. How to resume (checklist for the next AI)
|
||||||
|
|
||||||
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
|
1. Read this file, then `CHANGELOG.md` (last entries) and `.agent_memory_tasks.md`.
|
||||||
2. Confirm the environment: `git status`, current branch, `bal/VERSION`.
|
2. Confirm the environment: `git status`, current branch, and the `"version"` field of `bal/manifest.json`.
|
||||||
3. Run the full test suite (Section 3) — expect all green (266 as of v0.4.8).
|
3. Run the offline test files (Section 3) — expect the offline subset to pass
|
||||||
|
(414 passed; the 13 pre-existing failures in `test_core_will_invalidate.py`
|
||||||
|
and the 1 collection error in `test_group_i_basic_checkalive.py` are NOT
|
||||||
|
yours to fix without asking).
|
||||||
4. Talk to the owner in **Italian**, write everything else in **English**.
|
4. Talk to the owner in **Italian**, write everything else in **English**.
|
||||||
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
|
5. For any change: present a PLAN, wait for "OK" (R4), then implement, test,
|
||||||
build a ZIP, let the owner test, and only commit after explicit confirmation.
|
build a ZIP, let the owner test, and only commit after explicit confirmation.
|
||||||
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
|
6. Keep credit usage low: summarize, don't paste big code blocks; batch work.
|
||||||
7. When the owner confirms a ZIP works: commit (ZIP-first), sync with `main`,
|
7. When the owner confirms a ZIP works: commit (ZIP-first) directly on `main`,
|
||||||
squash to one commit, push, open a PR, merge it, then create/refresh a GitHub
|
push to `origin/main`, then run `./make-release.sh` to create the Gitea
|
||||||
**Release** with the ZIP attached (it becomes the owner's "Latest" download).
|
**Release** with the ZIP + signatures attached (it becomes the owner's
|
||||||
Always give the owner the PR URL and the Release URL.
|
"Latest" download). Always give the owner the Release URL.
|
||||||
|
|
||||||
|
### In progress: QR / audio will transfer (branch `feature/bal-qr-transfer`)
|
||||||
|
|
||||||
|
- The full QR-transfer feature (P0–P6) is implemented, tested and committed on
|
||||||
|
`feature/bal-qr-transfer` (commits `d288b55`, `ce3e36d`, pushed to
|
||||||
|
`origin`). PR creation URL:
|
||||||
|
`https://bitcoin-after.life/gitea/bitcoinafterlife/bal-electrum-plugin/pulls/new/feature/bal-qr-transfer`
|
||||||
|
- Included: core scheduler (`bal/core/qrtransfer.py`), `QR_CHUNK_SIZE`
|
||||||
|
setting (4 export presets), export/import dialogs + review/sign wizard +
|
||||||
|
lists/window wiring, export filters, auto slideshow with per-second rate +
|
||||||
|
loop option, audio send/receive buttons, and the crash fixes
|
||||||
|
(`status` default, `invalidate_will` guard). Docs: README, CHANGELOG entry
|
||||||
|
56, QML_PLAN, `AUDIO_MODEM_DEBIAN.md`.
|
||||||
|
- Follow-up refactor (CHANGELOG entry 57): all `copy.deepcopy` removed —
|
||||||
|
`copy_structure()` in `bal/core/util.py`, `WillItem.copy()` / ctor
|
||||||
|
serialize/deserialize, `copy_status_table()`. Working tree clean after the
|
||||||
|
branch's three commits.
|
||||||
|
- Verification: batch 377 passed / 2 pre-existing `test_bt_to_date_*`
|
||||||
|
failures; ruff no new violations; smoke + `build_zip.py` +
|
||||||
|
external-zip OK; pyright clean. The isolated
|
||||||
|
`test_heir_relative_anchor.py::test_karen7_frozen_delivery_not_expired`
|
||||||
|
failure is pre-existing test pollution (fails identically on clean HEAD,
|
||||||
|
passes inside the full batch) — not caused by entry 57.
|
||||||
|
- Remaining: manual on-device walkthrough of the QR path (and, if wanted,
|
||||||
|
the audio path — buttons only appear when the `audio_modem` plugin +
|
||||||
|
`amodem` are installed; see the prerequisites below).
|
||||||
|
|
||||||
|
### Animated-QR interop: BBQR only
|
||||||
|
|
||||||
|
- `bal/core/animated_qr.py` implements a stdlib-only codec for **BBQR**
|
||||||
|
(Coinkite `B$…` base32/hex/zlib frames), plus one shared
|
||||||
|
`AnimatedQrSession` with `detect_format` auto-detection and
|
||||||
|
`parse_for_detection` frame identity for the GUI debounce. The previously
|
||||||
|
supported **BAL QR**, **BC-UR v1** and **BC-UR v2** wire formats were
|
||||||
|
removed; only BBQR remains.
|
||||||
|
- **Sessions:** BBQR full-frame decode in any order for Z/2/H encodings,
|
||||||
|
duplicate drops.
|
||||||
|
- **Safety:** `_MAX_SESSION_PARTS = 20000`, `_MAX_MESSAGE_BYTES = 32 MB`,
|
||||||
|
`TransferConflictError` on a frame from a different transfer,
|
||||||
|
`SessionLimitError`, BBQR zlib-bomb guard, UTF-8 payloads only.
|
||||||
|
- **GUI:** `BalQrExportWidget` emits BBQR frames using the QR-size presets;
|
||||||
|
the importer routes every frame through `detect_format` +
|
||||||
|
`AnimatedQrSession.add_part` with the shared
|
||||||
|
`qr_import_accept_frame(state, fmt, session_key, frame_total, index,
|
||||||
|
payload, stable_reads=2)` debounce (reset on session-key change).
|
||||||
|
`_review_and_sign` resolves the session to the transfer text and decodes
|
||||||
|
parts uniformly.
|
||||||
|
- **Verification:** `tests/test_core_animated_qr.py` (BBQR round-trips,
|
||||||
|
encoding fallback, zlib-bomb guard, detection) and the extended
|
||||||
|
`tests/test_gui_qr_transfer.py` pass.
|
||||||
|
|
||||||
|
**Dev-box audio prerequisites (audio_modem channel):**
|
||||||
|
See `AUDIO_MODEM_DEBIAN.md` — the full Debian setup + verification, with the
|
||||||
|
two pitfalls (unversioned `libportaudio.so`, numpy>=2 `tostring` removal):
|
||||||
|
- `sudo ln -s /usr/lib/x86_64-linux-gnu/libportaudio.so.2 /usr/lib/x86_64-linux-gnu/libportaudio.so`
|
||||||
|
(the unversioned name the plugin loads; Debian ships only `.so.2`).
|
||||||
|
- numpy>=2 patch in `electrum/env/.../amodem/common.py`: `tostring()` →
|
||||||
|
`tobytes()` (already applied locally). Both are runtime-env fixes, not repo
|
||||||
|
changes; see CHANGELOG entry 56.
|
||||||
|
- To loop-test on one machine without speakers/mic: during receive,
|
||||||
|
`pactl set-default-source <sink>.monitor` (restore after).
|
||||||
|
|||||||
415
PLAN_CMDLINE_PLUGIN.md
Normal file
415
PLAN_CMDLINE_PLUGIN.md
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# Piano: supporto da riga di comando (CLI) per il plugin BAL
|
||||||
|
|
||||||
|
> **Stato**: solo piano. Nessun codice viene modificato finché il piano non viene approvato.
|
||||||
|
>
|
||||||
|
> **Versione di riferimento**: commit `2221389` (`core: anchor relative locktime/threshold recipes...`), working tree pulito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Obiettivo
|
||||||
|
|
||||||
|
Rendere il plugin **Bitcoin After Life** utilizzabile da riga di comando / daemon
|
||||||
|
di Electrum, senza GUI Qt, esponendo comandi per:
|
||||||
|
|
||||||
|
1. **Willexecutors** — elenco, aggiunta, modifica, selezione, eliminazione, import/export, ping, download lista.
|
||||||
|
2. **Heirs** — elenco, aggiunta, modifica, eliminazione, import/export.
|
||||||
|
3. **Impostazioni** — lettura e modifica (`settings set chiave=valore`), reset a default.
|
||||||
|
4. **Will** — ciclo di vita completo: visualizza stato, check di coerenza, prepara/ricostruisci, firma, import/merge, esporta, invalida, trasmette ai will-executor, verifica lato will-executor (searchtx).
|
||||||
|
|
||||||
|
Il tutto riusando **esclusivamente la logica già presente in `bal/core/`** (che è
|
||||||
|
già GUI-free) e senza importare mai PyQt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Stato attuale (verificato sul codice)
|
||||||
|
|
||||||
|
### 2.1 Meccanica di Electrum (4.8.0, checkout `electrum/`)
|
||||||
|
|
||||||
|
Ho verificato sul codice reale (`electrum/commands.py`, `electrum/plugin.py`,
|
||||||
|
`electrum/daemon.py`, `run_electrum`) i punti che governano i comandi dei plugin:
|
||||||
|
|
||||||
|
- **Registrazione comandi**: `@plugin_command(s, plugin_name)` in
|
||||||
|
`electrum/commands.py:2317`. Un comando plugin:
|
||||||
|
- è **sempre** un `async def`;
|
||||||
|
- viene registrato come `bal_<nome_funzione>` su `Commands` (quindi anche nel parser CLI);
|
||||||
|
- **forza il flag `'n'`** (richiede rete/daemon): *tutti* i comandi plugin richiedono un daemon in esecuzione e NON funzionano con `--offline`;
|
||||||
|
- alla chiamata inietta `plugin = daemon._plugins.get_plugin('bal')` (riga 2337).
|
||||||
|
- **Pre-parse CLI** (`run_electrum` riga 425): `Plugins(tmp_config, cmd_only=True)` importa solo l'`__init__.py` di ogni plugin abilitato per registrare i comandi nel parser. In modalità `cmd_only` il filtro `available_for` viene **saltato** (`plugin.py:128`), ma serve `config['plugins.bal.enabled'] is True` (`plugin.py:117`).
|
||||||
|
- **Daemon** (`daemon.py:626`): `Plugins(self.config, 'cmdline')`. Qui il filtro `available_for` **vale**: il plugin deve dichiarare `"cmdline"`.
|
||||||
|
- **Caricamento entry-point** (`plugin.py:622`): il daemon importa `electrum.plugins.bal.<gui_name>` con `gui_name='cmdline'`, quindi serve un modulo `bal/cmdline.py` con una classe `Plugin`.
|
||||||
|
- **Iniezione wallet**: il decorator `@command` (righe 170-194) gestisce i flag:
|
||||||
|
- `'w'` → risolve e inietta `wallet` da `daemon.get_wallet(wallet_path)` (il wallet deve essere già caricato con `electrum load_wallet`);
|
||||||
|
- `'p'` → richiede `--password` (o wallet già sbloccato) per le operazioni di firma.
|
||||||
|
- **Output**: il valore di ritorno del comando viene stampato come JSON da `run_electrum` (righe 626-630); in modalità daemon gli errori `UserFacingException` vengono stampati con exit code 1.
|
||||||
|
|
||||||
|
### 2.2 Il plugin (bal v0.6.1)
|
||||||
|
|
||||||
|
- `bal/core/` è già GUI-free e contiene tutta la logica riutilizzabile:
|
||||||
|
- `heirs.py` — `Heirs` (dict persistito in wallet DB, chiave `"heirs"`), validazione (`validate_heir`, `_validate`), `import_file`/`export_file`, `get_transactions`/`buildTransactions`.
|
||||||
|
- `willexecutors.py` — `Willexecutors` (config `bal_willexecutors`, chiave per `chainname`), `get_willexecutors`, `save`, `initialize_willexecutor`, `is_selected`, `is_valid`, `ping_servers_parallel`, `push_transactions_parallel`, `check_transactions_parallel`, `check_transaction`, `download_list`, `get_willexecutors_list_from_json`.
|
||||||
|
- `will.py` — `Will` (statiche) e `WillItem` (stato per-tx: `VALID/COMPLETE/PUSHED/CHECKED/...`), `is_will_valid`, `check_will`, `check_willexecutors_and_heirs`, `invalidate_will`, `normalize_will`, `get_min_locktime`, `get_tx_from_any`, `set_check_willexecutor`, `save_valid_transactions_to_history`.
|
||||||
|
- `plugin_base.py` — `BalPlugin` (tutte le `BalConfig`: chiavi `bal_*`), `BalTimestamp`, `get_version`, registrazione dei dict `heirs`/`will`/`will_settings` nel wallet DB.
|
||||||
|
- `checkalive.py` — `resolve_date_to_check`, `resolve_relative_check_alive_date`, `ensure_check_alive_valid` (riferimento temporale unico per ogni check; relativo negativo = prima della delivery, positivo = dopo la creazione).
|
||||||
|
- `util.py` — `Util` (locktime, quantità, confronto tx/heirs, `get_available_utxos`, `fix_will_settings_tx_fees`).
|
||||||
|
- `bal/gui/qt/window.py` — `BalWindow` contiene i flussi da **replicare in headless** (non riusabile direttamente perché legato a Qt):
|
||||||
|
- `init_will` (riga 151), `load_willitems`/`save_willitems` (120/129),
|
||||||
|
- `init_class_variables` (618) e `build_will` (397),
|
||||||
|
- `build_inheritance_transaction` (678) → il flusso completo "prepara will",
|
||||||
|
- `sign_transactions` (952), `ask_password_and_sign_transactions` (1084),
|
||||||
|
- `push_transactions_to_willexecutors` (1164), `broadcast_transactions` (1127),
|
||||||
|
- `check_transactions_task`/`check_transactions` (1414/1464),
|
||||||
|
- `export_json_file` (1246), `merge_will` (1264), `merge_will_from_file` (1348), `_load_will_file` (1406),
|
||||||
|
- `invalidate_will` (917).
|
||||||
|
- `bal/manifest.json`: `"available_for": ["qt"]`, `"version": "0.6.1"`.
|
||||||
|
- `build_zip.py`: cammina ricorsivamente su `bal/` (esclude `__pycache__`, `.pyc`), quindi **includerà automaticamente** i nuovi file di `bal/cli/` e `bal/cmdline.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architettura proposta
|
||||||
|
|
||||||
|
```
|
||||||
|
bal/
|
||||||
|
__init__.py # MODIFICATO: importa ``from .cli import commands`` (registra i comandi)
|
||||||
|
cmdline.py # NUOVO: shim zip-safe (come qt.py) che ri-espone Plugin da bal.cli.plugin
|
||||||
|
cli/
|
||||||
|
__init__.py # NUOVO
|
||||||
|
commands.py # NUOVO: tutti i @plugin_command (async), sottili, delegano al controller
|
||||||
|
controller.py # NUOVO: BalController — facciata headless per-wallet (replica di BalWindow senza Qt)
|
||||||
|
plugin.py # NUOVO: class Plugin(BalPlugin) — entry-point per il daemon (gui_name='cmdline')
|
||||||
|
manifest.json # MODIFICATO: available_for = ["qt", "cmdline"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Principi:
|
||||||
|
|
||||||
|
- **`bal/cli/` non importa mai Qt** (stessa regola di `bal/core/`). Può importare solo `bal.core`, `electrum.*` e stdlib.
|
||||||
|
- **`commands.py` = livello di trasporto**: firma `async def bal_x(self, wallet=None, plugin=None, ...)`, valida/parsa argomenti, chiama il controller, ritorna strutture JSON-serializzabili. Zero logica di business.
|
||||||
|
- **`controller.py` = il cuore**: replica i passi GUI-free di `BalWindow`, ma con errori espressi come eccezioni (i messaggi GUI `show_message`/`show_error` diventano raise/ritorni), e persiste esplicitamente su wallet DB.
|
||||||
|
- **`plugin.py`** è quasi vuoto: eredita `BalPlugin.__init__` e basta (serve solo perché Electrum istanzi `module.Plugin(self, config, name)`).
|
||||||
|
- **Nessuna dipendenza nuova** richiesta: `aiohttp`, `dns` e il resto sono già usati da `bal/core`.
|
||||||
|
|
||||||
|
### 3.1 Perché i comandi richiedono il daemon
|
||||||
|
|
||||||
|
`plugin_command` forza il flag `'n'` in `commands.py:2321-2322`. Conseguenza
|
||||||
|
architetturale da documentare chiaramente:
|
||||||
|
|
||||||
|
```
|
||||||
|
electrum daemon -d # avvia il daemon (rete + plugin cmdline)
|
||||||
|
electrum load_wallet # carica/sblocca il wallet
|
||||||
|
electrum bal_heirs_list # i comandi BAL girano contro il daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Questa è la stessa limitazione di tutti gli altri plugin con comandi CLI
|
||||||
|
(es. `swapserver`, `nwc`). Non è aggirabile senza hackare `plugin_command`, che
|
||||||
|
escludiamo dal piano.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Modifiche ai file esistenti
|
||||||
|
|
||||||
|
### 4.1 `bal/manifest.json`
|
||||||
|
- `"available_for": ["qt", "cmdline"]`.
|
||||||
|
|
||||||
|
Nessun cambio di versione necessario per lo sviluppo; la versione si alzerà in
|
||||||
|
`make-release.sh` come già avviene.
|
||||||
|
|
||||||
|
### 4.2 `bal/__init__.py`
|
||||||
|
- Aggiungere in fondo:
|
||||||
|
```python
|
||||||
|
# Registra i comandi CLI (bal_*) appena Electrum importa il pacchetto,
|
||||||
|
# sia in modalità cmd_only (pre-parse) sia nel daemon.
|
||||||
|
from . import cli # noqa: F401 (importa bal.cli.commands, che registra i @plugin_command)
|
||||||
|
```
|
||||||
|
(oppure `from .cli import commands` esplicito).
|
||||||
|
- Accortezza: `bal/cli/commands.py` deve essere importabile **senza Qt** e senza
|
||||||
|
effetti collaterali pesanti, perché viene importato anche nel pre-parse CLI e
|
||||||
|
all'avvio della GUI.
|
||||||
|
|
||||||
|
### 4.3 `build_zip.py`
|
||||||
|
- Nessuna modifica obbligatoria: il walker include già `cli/` e `cmdline.py`.
|
||||||
|
- **Opzionale (consigliato)**: aggiungere una stampa di avviso quando l'archivio
|
||||||
|
contiene sia `cmdline.py` che `qt.py`, e verificare che `manifest.json` abbia
|
||||||
|
entrambi i valori in `available_for`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Nuovi file
|
||||||
|
|
||||||
|
### 5.1 `bal/cmdline.py` (shim, ~stesso schema di `qt.py`)
|
||||||
|
|
||||||
|
Riproduce il pattern zip-safe di `qt.py` (creazione dei package intermedi in
|
||||||
|
`sys.modules`, import via `importlib.import_module`), ma punta a
|
||||||
|
`bal.cli.plugin`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Plugin = _plugin_module.Plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `bal/cli/plugin.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Plugin(BalPlugin):
|
||||||
|
def __init__(self, parent, config, name):
|
||||||
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
|
```
|
||||||
|
|
||||||
|
Niente hook Qt, niente `bal_windows`. Il daemon lo istanzia quando
|
||||||
|
`get_plugin('bal')` viene chiamato dal wrapper di `plugin_command`.
|
||||||
|
|
||||||
|
### 5.3 `bal/cli/controller.py` — `BalController`
|
||||||
|
|
||||||
|
Facciata per-wallet che incapsula lo stato e i flussi. Attributi (speculari a
|
||||||
|
`BalWindow`):
|
||||||
|
- `plugin` (il `BalPlugin`/`Plugin` iniettato),
|
||||||
|
- `wallet` (iniettato da Electrum),
|
||||||
|
- `will_settings` (da `plugin.WILL_SETTINGS.get()` + `Util.fix_will_settings_tx_fees`),
|
||||||
|
- `heirs` (`Heirs(wallet)` validati),
|
||||||
|
- `willexecutors` (`Willexecutors.get_willexecutors(plugin)`),
|
||||||
|
- `willitems` (da `wallet.db.get_dict("will")` → `WillItem(w, wallet=wallet)`),
|
||||||
|
- `date_to_check` (via `resolve_date_to_check`).
|
||||||
|
|
||||||
|
Metodi principali (replicano le funzioni Qt, senza dialoghi):
|
||||||
|
|
||||||
|
| Metodo | Replica di (`window.py`) | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `load_willitems()` | 120 | Costruisce i `WillItem` dal dict `will` del wallet DB. |
|
||||||
|
| `save_willitems()` | 129 | `to_dict()` con `tx` serializzato a stringa, `json.dumps` di prova, scrittura su `wallet.db` + `wallet.save_db()`. |
|
||||||
|
| `init_class_variables()` | 618 | `date_to_check`, `no_willexecutor`, `willexecutors`, check `ensure_check_alive_valid`. |
|
||||||
|
| `check_will()` | 473 | `Will.is_will_valid(...)`; le eccezioni di dominio vengono propagate al comando. |
|
||||||
|
| `build_inheritance_transaction()` | 678 | Flusso 1/7→2/7 replicato: `Will.check_amounts`, guardie locktime/willexecutor, `check_will()` e rebuild su `NotCompleteWillException`. Le `show_message/show_error` diventano raise (`UserFacingException` con testo chiaro) oppure ritorni `{"status": "postponed", "invalidation": tx}`. |
|
||||||
|
| `sign_transactions(password)` | 952 | Firma i `VALID` non completi: fixup input dai willitems padre, `wallet.sign_transaction(tx, password, ignore_warnings=True)`, `set_status("COMPLETE")`, `check_signatures`. |
|
||||||
|
| `push_transactions_to_willexecutors(force)` | 1164 | `get_willexecutor_transactions` + `push_transactions_parallel` + gestione "already present" con `check_transaction`. Aggiorna `PUSHED/PUSH_FAIL`. |
|
||||||
|
| `check_transactions()` | 1414 | `check_transactions_parallel` + `set_check_willexecutor(res)` per item. |
|
||||||
|
| `export_json_file(path)` | 1246 | `write_json_file(path, {wid: wi.to_dict()...})` con `tx` come stringa (formato identico a `_load_will_file`). |
|
||||||
|
| `merge_will_from_file(path)` | 1348 | `_load_will_file` + `merge_will` (stessa semantica di `window.py:1264`). |
|
||||||
|
| `_load_will_file(path)` | 1406 | `read_json_file` + `tx_from_any` + `WillItem`. |
|
||||||
|
| `invalidate_will()` | 917 | `Will.invalidate_will(...)` con `history_label` e `will_locktime`. |
|
||||||
|
| `fetch_will_executors_list()` / `ping()` | 1491/1771 | `download_list(old, welist_server)` + `ping_servers_parallel`, poi `Willexecutors.save(plugin, ...)`. |
|
||||||
|
| `apply_settings(cfg_name, value)` | — | Mappa il nome chiave all'attributo `BalConfig` del plugin e fa `set(...)`. |
|
||||||
|
|
||||||
|
Regole di persistenza (fondamentali):
|
||||||
|
- **heirs** → `heirs.save()` (via `__setitem__`/`pop` già implementati) + `wallet.save_db()`.
|
||||||
|
- **will** → `save_willitems()` + `wallet.save_db()`.
|
||||||
|
- **willexecutors** → `Willexecutors.save(plugin, willexecutors)` (config, non wallet DB).
|
||||||
|
- **settings** → `BalConfig.set(...)` (config).
|
||||||
|
|
||||||
|
### 5.4 `bal/cli/commands.py` — comandi (tutti `async def` + `@plugin_command`)
|
||||||
|
|
||||||
|
Firma standard: `async def bal_x(self, wallet=None, plugin=None, ...)`. Flag:
|
||||||
|
- `'n'` — imposto automaticamente da `plugin_command` (rete/daemon).
|
||||||
|
- `'w'` — wallet richiesto e iniettato da Electrum.
|
||||||
|
- `'p'` — solo per i comandi che firmano (richiede `--password`).
|
||||||
|
|
||||||
|
Tutti i comandi costruiscono `controller = BalController(plugin, wallet)` e
|
||||||
|
ritornano strutture JSON-serializzabili. Elenco completo al §6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tabella comandi
|
||||||
|
|
||||||
|
Convenzioni:
|
||||||
|
- `<WALLET>`: wallet caricato nel daemon (non serve passarlo; Electrum usa quello
|
||||||
|
configurato o `--wallet`).
|
||||||
|
- Output: `list`/`dict` stampati come JSON; exit 0 su successo, 1 su errore.
|
||||||
|
- `*` = richiede password (`--password`) se il wallet è cifrato.
|
||||||
|
|
||||||
|
### 6.1 Willexecutors
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_willexecutors_list` | `nw` | — | Elenco `{url: {address, base_fee, status, info, selected, last_update, sort}}` per la chain corrente. |
|
||||||
|
| `bal_willexecutors_show` | `nw` | `url` | Dettaglio di un singolo will-executor. |
|
||||||
|
| `bal_willexecutors_add` | `nw` | `url` `address` `base_fee` | Aggiunge/aggiorna un will-executor (via `initialize_willexecutor`), `selected=false` di default. Ritorna il record. |
|
||||||
|
| `bal_willexecutors_update` | `nw` | `url` `[address]` `[base_fee]` `[info]` `[promo_code]` | Modifica i campi indicati e salva. |
|
||||||
|
| `bal_willexecutors_select` | `nw` | `url` `value` | `is_selected(we, eval_bool(value))` + salva. |
|
||||||
|
| `bal_willexecutors_delete` | `nw` | `url` | Rimuove dalla lista e salva. |
|
||||||
|
| `bal_willexecutors_ping` | `nw` | `[url]` | `ping_servers_parallel` (tutti o uno); aggiorna `status/base_fee/address`; salva. Output: risultati per url. |
|
||||||
|
| `bal_willexecutors_download` | `nw` | — | `download_list(old, plugin.WELIST_SERVER.get())`; unisce e salva. Output: n. record. |
|
||||||
|
| `bal_willexecutors_import` | `nw` | `path` | Legge un JSON `{url: record}` (stesso formato di export), `initialize_willexecutor` per record, salva. |
|
||||||
|
| `bal_willexecutors_export` | `nw` | `path` | Scrive `{url: record}` su file JSON. |
|
||||||
|
|
||||||
|
### 6.2 Heirs
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_heirs_list` | `nw` | — | `{name: [address, amount, locktime, ...]}` (tutte le colonne `HEIR_*`). |
|
||||||
|
| `bal_heirs_show` | `nw` | `name` | Dettaglio di un singolo heir. |
|
||||||
|
| `bal_heirs_add` | `nw` | `name` `address` `amount` `locktime` | Valida con `Heirs.validate_heir` (OP_RETURN incluso) e salva. `amount` può essere satoshi o `"50%"`. `locktime` può essere timestamp assoluto o relativo `"30d"`/`"1y"`. |
|
||||||
|
| `bal_heirs_update` | `nw` | `name` `[address]` `[amount]` `[locktime]` | Modifica i campi indicati (ri-validazione) e salva. |
|
||||||
|
| `bal_heirs_delete` | `nw` | `name` | `heirs.pop(name)` + `save_db()`. |
|
||||||
|
| `bal_heirs_import` | `nw` | `path` | `Heirs.import_file(path)` (validazione + merge). |
|
||||||
|
| `bal_heirs_export` | `nw` | `path` | `Heirs.export_file(path)`. |
|
||||||
|
|
||||||
|
### 6.3 Impostazioni
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_settings_list` | `n` | — | Elenco di tutte le `BalConfig` del plugin: `{chiave: {value, default, name}}` (nome leggibile). |
|
||||||
|
| `bal_settings_get` | `n` | `key` | Valore corrente di una chiave (`bal_*`). |
|
||||||
|
| `bal_settings_set` | `n` | `key=value` | Scrive il valore (conversione di tipo: bool/int/str/JSON) via `BalConfig.set(...)`. `bal_will_settings` accetta JSON. |
|
||||||
|
| `bal_settings_reset` | `n` | `key` | `BalConfig.set(cfg.default)`. |
|
||||||
|
|
||||||
|
### 6.4 Will
|
||||||
|
|
||||||
|
| Comando | Flag | Argomenti | Descrizione / output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bal_will_status` | `nw` | — | Per ogni `wid` (txid): locktime, `heirsvalue`, executor, flag di stato (`VALID/COMPLETE/PUSHED/CHECKED/CHECK_FAIL/...`), `sigs_have/sigs_required`, `tx_fees`, executor URL. |
|
||||||
|
| `bal_will_check` | `nw` | — | `check_will()` (coerenza heirs+executor+fees+locktime, in locale). Ritorna `{"valid": true}` o un errore esplicito (es. `HeirNotFound`, `WillPostponed`, `WillExpired`, `NoHeirs`). |
|
||||||
|
| `bal_will_prepare` | `nw` | — | Flusso completo `build_inheritance_transaction`: check → rebuild se non coerente → persiste. Output: riepilogo tx nuova/aggiornata per wid. |
|
||||||
|
| `bal_will_sign` | `nwp` | `[txid]` | Firma i `VALID` non completi (o solo `txid`). Aggiorna `COMPLETE` e `sigs_*`; persiste. Output per txid. |
|
||||||
|
| `bal_will_broadcast` | `nw` | `[txid]` `force` | `push_transactions_to_willexecutors(force, txids)` parallelo; aggiorna `PUSHED/PUSH_FAIL`. Output: `{url: status}`. |
|
||||||
|
| `bal_will_export` | `nw` | `path` | `export_json_file(path)`. |
|
||||||
|
| `bal_will_import_merge` | `nw` | `path` | `merge_will_from_file(path)` (stessa semantica GUI: merge psbt/stati, mai perdere una tx viva). |
|
||||||
|
| `bal_will_invalidate` | `nw` | — | `Will.invalidate_will(...)`; ritorna la tx di invalidazione (da firmare+trasmettere con i comandi sopra). |
|
||||||
|
| `bal_will_check_executor` | `nw` | `[txid]` | Verifica lato will-executor: `check_transactions_parallel` (searchtx) per i `VALID+PUSHED` non `CHECKED`; applica `set_check_willexecutor`. Output: `{wid: {url, checked, ok}}`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Flusso dati e persistenza
|
||||||
|
|
||||||
|
```
|
||||||
|
CLI (electrum bal_*) Daemon (Electrum 4.8.0)
|
||||||
|
┌───────────────────────┐ ┌──────────────────────────────────────┐
|
||||||
|
│ run_electrum │ RPC │ Daemon.run_cmdline │
|
||||||
|
│ pre-parse cmd_only │ ─────────────► │ plugin_command wrapper │
|
||||||
|
│ -> importa bal │ jsonrpc │ inietta plugin + wallet │
|
||||||
|
│ (registra bal_*) │ │ bal/cli/commands.py │
|
||||||
|
└───────────────────────┘ │ -> BalController(plugin, wallet) │
|
||||||
|
│ -> bal.core.* │
|
||||||
|
│ -> wallet.db / config (persist) │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Lettura**: `wallet.db.get_dict("will")` (wills), `Heirs(wallet)` (heirs),
|
||||||
|
`plugin.WILLEXECUTORS.get()`/`plugin.WILL_SETTINGS.get()` (config).
|
||||||
|
- **Scrittura**: `save_willitems()` → `wallet.db` + `wallet.save_db()`;
|
||||||
|
`heirs.save()`; `Willexecutors.save(...)`; `BalConfig.set(...)`.
|
||||||
|
- **Firma**: `wallet.sign_transaction(tx, password, ignore_warnings=True)` —
|
||||||
|
idem GUI, quindi compatibile con multisig e wallet cifrati (password via `--password`).
|
||||||
|
- **Rete**: `Network.get_instance()` già usato da `bal/core/willexecutors.py`
|
||||||
|
(i comandi `'n'` garantiscono rete attiva).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Errori, exit code, output
|
||||||
|
|
||||||
|
- Ritorno `None` → nessun output; `str` → stampato; `dict`/`list` → `json_encode`.
|
||||||
|
- Errori utente: sollevare `electrum.util.UserFacingException(msg)` → in modalità
|
||||||
|
daemon viene stampato `msg` con exit 1.
|
||||||
|
- Errori di dominio BAL (`WillExpiredException`, `WillPostponedException`,
|
||||||
|
`HeirNotFoundException`, `NoWillExecutorNotPresent`, `CheckAliveError`,
|
||||||
|
`AmountException`, ...): il controller le converte in `UserFacingException`
|
||||||
|
con testo in chiaro (riuso dei messaggi già presenti, senza HTML/Qt).
|
||||||
|
- Convenzione consigliata per comandi che producono più di un risultato:
|
||||||
|
ritornare un `dict` con chiave `"result"`/`"warnings"` quando servono avvisi
|
||||||
|
(es. dopo `prepare` con heirs scartati per dust).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Compatibilità Electrum 4.7.2 / 4.8.0
|
||||||
|
|
||||||
|
- `plugin_command`, il wrapper `@command` e `daemon._plugins.get_plugin` esistono
|
||||||
|
in entrambe le versioni (verificati su 4.8.0; usati identici da `swapserver`).
|
||||||
|
- Il `BalPlugin` già gestisce il cambio API di registrazione dict
|
||||||
|
(`json_db.register_dict` vs `stored_dict.register_name`): nessun intervento.
|
||||||
|
- `available_for: ["cmdline"]` è lo stesso meccanismo di `trustedcoin`
|
||||||
|
(che ha già `cmdline.py` in 4.8.0).
|
||||||
|
- **Nessun nuovo import Qt** in `bal/cli/`: verificabile in CI con un check
|
||||||
|
statico su `bal/cli/*.py` e `bal/cmdline.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Build / release
|
||||||
|
|
||||||
|
- `python3 build_zip.py` produce `bal-electrum-plugin.zip` con `cli/`, `cmdline.py`
|
||||||
|
e il manifest aggiornato. Lo zip serve sia per la GUI che per il daemon.
|
||||||
|
- Il test `external_zip_test.py` andrà esteso (vedi §11) per verificare che il
|
||||||
|
zip, caricato da Electrum, registri anche i comandi `bal_*`.
|
||||||
|
- Nessun cambiamento a `make-release.sh` (la versione resta nel manifest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Piano di test e verifica
|
||||||
|
|
||||||
|
### 11.1 Nuovi test standalone (stile repo: `tests/test_*.py` con `if __name__ == "__main__"`)
|
||||||
|
|
||||||
|
- `tests/test_cli_commands_registered.py` (runtime env):
|
||||||
|
- importa `electrum.plugins.bal` con `Plugins(config, cmd_only=True)`;
|
||||||
|
- asserisce che `known_commands` contenga tutti i nomi `bal_*` della tabella;
|
||||||
|
- asserisce che ogni funzione sia coroutine e abbia il flag `n`.
|
||||||
|
- `tests/test_cli_controller.py` (runtime env, offline, senza rete):
|
||||||
|
- wallet "fake"/temporaneo (pattern di `test_core_heirs.py`);
|
||||||
|
- CRUD heirs e willexecutors, settings get/set/reset, export/import will
|
||||||
|
(merge), build will con fixtures note.
|
||||||
|
- `tests/test_cli_zip.py` (o estensione di `external_zip_test.py`):
|
||||||
|
- costruisce lo zip, lo carica come `electrum_external_plugins.bal` con
|
||||||
|
`Plugins(config, 'cmdline')`, asserisce `available_for` include `"cmdline"`
|
||||||
|
e che `get_plugin('bal')` restituisca il `Plugin` di `bal.cli.plugin`
|
||||||
|
(nessun import Qt eseguito).
|
||||||
|
- `tests/test_cli_will_flows.py` (offline, dove possibile):
|
||||||
|
- prepare → sign → export → merge su un wallet di test con heirs fissi;
|
||||||
|
- verifica che `wallet.db.get_dict("will")` rifletta COMPLETE/PUSHED dopo
|
||||||
|
le operazioni che non toccano rete.
|
||||||
|
|
||||||
|
### 11.2 Verifica manuale (da documentare nel README/HANDOFF)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
electrum daemon -d
|
||||||
|
electrum load_wallet
|
||||||
|
electrum bal_heirs_list
|
||||||
|
electrum bal_settings_list
|
||||||
|
electrum bal_will_status
|
||||||
|
electrum bal_will_prepare
|
||||||
|
electrum bal_will_sign --password '...' # se wallet cifrato
|
||||||
|
electrum bal_will_broadcast
|
||||||
|
electrum bal_will_check_executor
|
||||||
|
electrum bal_willexecutors_ping
|
||||||
|
electrum stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Regressione
|
||||||
|
|
||||||
|
- `QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal`
|
||||||
|
deve continuare a passare (prova che `bal/__init__` + Qt convivono con il
|
||||||
|
nuovo import di `bal.cli.commands`).
|
||||||
|
- Eseguire i `test_core_*.py` esistenti (nessuna logica core toccata).
|
||||||
|
- Ruff: evitare nuove violazioni in `bal/cli/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Rischi e decisioni aperte
|
||||||
|
|
||||||
|
1. **Daemon obbligatorio** (non `--offline`): imposto da `plugin_command`.
|
||||||
|
→ Accettato; documentato al §3.1.
|
||||||
|
2. **Wallet pre-caricato**: i comandi `w` falliscono con "wallet not loaded" se
|
||||||
|
non si lancia prima `electrum load_wallet`. → Documentare.
|
||||||
|
3. **`bal/__init__.py` che importa `bal.cli.commands`**: viene eseguito anche
|
||||||
|
all'avvio della GUI. `commands.py` deve restare leggero (solo definizioni +
|
||||||
|
import di `electrum.commands` e `bal.core`). Da verificare con `smoke_test.py`.
|
||||||
|
4. **Doppio caricamento**: se un install è contemporaneamente interno E zip
|
||||||
|
esterno, la seconda importazione di `commands.py` potrebbe sollevare
|
||||||
|
"Command name bal_... already exists". Pratica corrente: un solo install;
|
||||||
|
si può mitigare con un guard `if not getattr(module, '_registered')`.
|
||||||
|
5. **OP_RETURN heirs** in CLI: gestiti come in GUI (`validate_op_return_hex`,
|
||||||
|
colonne quantità `"0"`). Da testare.
|
||||||
|
6. **Persistenza `will_settings`**: oggi letta dalla config globale
|
||||||
|
(`bal_will_settings`) in `BalWindow.__init__`, non dal wallet DB. Il
|
||||||
|
controller deve replicare esattamente questo (config), non introdurre una
|
||||||
|
seconda sorgente.
|
||||||
|
7. **Multisig**: la firma usa `wallet.sign_transaction` → supportata; il flusso
|
||||||
|
"merge PSBT" copre la firma parziale. Test dedicato con wallet multisig in
|
||||||
|
fase di implementazione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Fasi di implementazione (ordine proposto)
|
||||||
|
|
||||||
|
1. `bal/cli/__init__.py`, `bal/cli/plugin.py`, `bal/cmdline.py`, update
|
||||||
|
`bal/manifest.json` + `bal/__init__.py`.
|
||||||
|
2. `tests/test_cli_commands_registered.py` + verifica `smoke_test.py`.
|
||||||
|
3. `bal/cli/controller.py` (read-only: status/list/show) → `commands.py` per
|
||||||
|
willexecutors/heirs/settings (senza rete).
|
||||||
|
4. Comandi will: `prepare`, `sign`, `export`, `import_merge`, `invalidate`.
|
||||||
|
5. Comandi di rete: `ping`, `download`, `broadcast`, `check_executor`.
|
||||||
|
6. Test zip (`test_cli_zip.py`), estensione `external_zip_test.py`, prova
|
||||||
|
manuale col daemon, aggiornamento README/HANDOFF.
|
||||||
453
PLAN_I18N.md
Normal file
453
PLAN_I18N.md
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
# PLAN_I18N — Italian translation of the BAL Electrum plugin (i18n)
|
||||||
|
|
||||||
|
> **Status:** DRAFT. Every phase still needs the owner's explicit "OK" before any code is written (HANDOFF.md, rule R4).
|
||||||
|
> **Prepared:** 2026-09-25, from a read-only analysis (no code was changed). Owner's decisions recorded on 2026-09-26 (section 6).
|
||||||
|
> **Code base analysed:** `bal-electrum-plugin` `main` @ `4a33116` (2026-09-19, manifest `0.7.0`) and Electrum `4.8.0`.
|
||||||
|
> **Line numbers** refer to that commit. After merging a newer `main`, find the code by content.
|
||||||
|
|
||||||
|
## 0. Read this first
|
||||||
|
|
||||||
|
1. Read `HANDOFF.md` (rules R1–R4, ZIP-FIRST, CREDIT-SAVING) and `AGENTS.md` (venvs, tests). They still apply.
|
||||||
|
2. **One exception to HANDOFF.md.** Section 5 ("Git / delivery workflow") and section 7, step 7, say: work directly on `main`, push to `origin/main`, run `make-release.sh`. **That does NOT apply to this task.** See section 3 (dedicated branch).
|
||||||
|
3. The owner's decisions (D1–D7) are recorded in section 6. For anything not covered there, ask the owner (rule R3).
|
||||||
|
|
||||||
|
## 1. Goal and scope
|
||||||
|
|
||||||
|
**Goal:** when Electrum is set to Italian, the whole BAL interface appears in Italian. The mechanism must allow adding more languages later just by adding one `.po` file.
|
||||||
|
|
||||||
|
**In scope**
|
||||||
|
- The Qt GUI of the plugin (`bal/gui/qt/`) and the GUI-free strings it displays (`bal/core/`).
|
||||||
|
- Languages: Italian (`it_IT`) and Spanish (`es_ES`).
|
||||||
|
|
||||||
|
**Out of scope** (owner's decisions, or by design)
|
||||||
|
- **BAL Easy Heirs**: excluded by the owner.
|
||||||
|
- **CLI output** (`bal/cli/`): stays English, exactly like Electrum (`run_electrum`: "the CLI is intentionally always non-localized").
|
||||||
|
- `bal/wallet_util/` (standalone helper scripts), the Android reader (`android/`, which uses Android resources), the manual, the website and the docs.
|
||||||
|
- Other languages: later. The infrastructure will support them.
|
||||||
|
|
||||||
|
## 2. Background (verified)
|
||||||
|
|
||||||
|
### 2.1 How Electrum 4.8.0 does it
|
||||||
|
|
||||||
|
- **gettext with one domain, `electrum`.** Catalogs live in `electrum/locale/locale/<lang>/LC_MESSAGES/electrum.mo` (`electrum/i18n.py`, `LOCALE_DIR`). Every string goes through `electrum.i18n._()`.
|
||||||
|
- **The language is set once, at startup, for the GUI only** (`run_electrum` L433–451):
|
||||||
|
- it uses `config.LOCALIZATION_LANGUAGE` (config key `language`);
|
||||||
|
- if that is empty, it uses `electrum.gui.default_lang.get_default_language(gui_name)`, i.e. `QLocale.system().name()`, **but only if** that value is in `electrum.i18n.languages`; otherwise `en_UK`;
|
||||||
|
- `en_*` means no translation (the English source strings are shown);
|
||||||
|
- changing the language requires a restart.
|
||||||
|
- **Strings evaluated too early stay English.** Anything evaluated before `set_language()` (for example at import time) is not translated.
|
||||||
|
- **Safety check inside `_()`.** The decorator `_ensure_translation_keeps_format_string_syntax_similar` rejects a translation whose `{}` fields differ from the source. `_("")` returns `""`. There is an optional `context=` argument (pgettext).
|
||||||
|
- **Coding rules** (comments in `i18n.py`): no f-strings inside `_()`, no `%` formatting; write `_("… {}").format(x)`. There are no plural forms (`ngettext` is not used anywhere).
|
||||||
|
- **Pipeline:**
|
||||||
|
1. `contrib/locale/push_locale.py` runs xgettext over `electrum/**/*.py`. This includes *internal* plugins.
|
||||||
|
2. The strings go to Crowdin, where volunteers translate them.
|
||||||
|
3. The `.po` files are stored in the `electrum-locale` repo.
|
||||||
|
4. `contrib/locale/build_locale.sh` compiles them with msgfmt into `.mo` files and writes `stats.json`.
|
||||||
|
- **Translation security:**
|
||||||
|
- `electrum-locale/update.py` refuses any translation whose `msgstr` contains a Bitcoin address, an e-mail address, a URL, or a long word mixing letters and digits;
|
||||||
|
- `llm_proofreader/` checks every translation PR for vandalism in CI.
|
||||||
|
- **External plugins:**
|
||||||
|
- they are always ZIP files, imported with `zipimport` as `electrum_external_plugins.<name>`;
|
||||||
|
- `BasePlugin.read_file(filename) -> bytes` reads a file from inside the ZIP, or from the internal plugin folder;
|
||||||
|
- **Electrum provides nothing for translating external plugins.** Their strings are not in `electrum.mo`.
|
||||||
|
- `i18n.py`, `gui/default_lang.py` and `read_file()` are identical in 4.7.2, 4.8.0 and 4.8.2.
|
||||||
|
- **Swiss locales** (`it_CH`, `de_CH`, `fr_CH`) are not in Electrum's list, so Electrum starts in English until the user picks a language. BAL must simply follow Electrum and not detect the language itself. Otherwise the UI would be half in one language and half in another.
|
||||||
|
|
||||||
|
### 2.2 BAL today (`main` @ `4a33116`)
|
||||||
|
|
||||||
|
- Source strings are in English. Most are wrapped in `_`, imported from `electrum.i18n` in these places:
|
||||||
|
- `gui/qt/common.py:55`, which re-exports it to every `gui/qt` module;
|
||||||
|
- `gui/qt/theme.py:90,108`;
|
||||||
|
- `core/will.py:32` and `core/willexecutors.py:25`;
|
||||||
|
- `cli/controller.py:29`.
|
||||||
|
- The package has 483 `_()` calls in 11 files. xgettext finds 365 distinct strings (≈ 2,230 words).
|
||||||
|
- **With Electrum set to Italian, only 39 of the 365 strings (≈ 11 %) appear in Italian.** They are the generic words that Electrum already translates (Cancel, Save, Address…). Everything specific to BAL stays in English.
|
||||||
|
- **Issues to fix before translating** (exact locations are in the Appendix):
|
||||||
|
- 14 f-strings or `.format()` calls inside `_()`: these strings can never be translated (Ruff INT001/INT002);
|
||||||
|
- 15 `_()` calls with a non-literal argument: extraction tools cannot find the text;
|
||||||
|
- 14 concatenations around `_()`: the word order breaks in other languages;
|
||||||
|
- 18 `_()` calls evaluated at import time (in class bodies): they would stay English (see 4.2);
|
||||||
|
- 2 × `_("")`: plain gettext returns the catalog header instead of an empty string;
|
||||||
|
- 12 settings help texts (`HelpButton("…")`) and the help texts of 5 `add_widget()` calls are never translated (≈ 500 words in total);
|
||||||
|
- other text shown to the user without `_()`.
|
||||||
|
- **Some stored data depends on the UI language.**
|
||||||
|
- `WillItem.set_status()` (`core/will.py:1344`) appends the *translated* status label to `WillItem.status`.
|
||||||
|
- That field is saved via `to_dict()` in the wallet DB (`wallet.db.get_dict("will")`, `gui/qt/window.py:232`), in exported files and in QR transfers.
|
||||||
|
- Real example from `tests/samanta7`: `"New.Firmato.Pushed.Checked.Replaced"`. Electrum's Italian catalog already translates *Signed*, *Error* and *Expired*, so the saved history mixes languages today.
|
||||||
|
- **Default texts in `core/plugin_base.py` exist only in English:**
|
||||||
|
- `HISTORY_LABEL` (around L246, token `{willexecutor}`);
|
||||||
|
- `EVENT_DESCRIPTION` and `EVENT_SUMMARY` (around L331–337, tokens `$wallet_name` and `$heirs_complete`).
|
||||||
|
|
||||||
|
## 3. Branch and workflow for this task
|
||||||
|
|
||||||
|
The owner decided, on Truman's suggestion, to do this work on a **separate branch on Gitea**, not on `main`.
|
||||||
|
|
||||||
|
- **Branch:** `feature/i18n` (proposed name, see D6), created from `main`.
|
||||||
|
- **Never** commit or push to `main` during this task.
|
||||||
|
- **Never** run `make-release.sh` from the branch: it tags and publishes a Gitea release. Build test ZIPs with `python3 build_zip.py` only.
|
||||||
|
- **At the start of every phase:**
|
||||||
|
1. merge `origin/main` into `feature/i18n`;
|
||||||
|
2. resolve any conflicts;
|
||||||
|
3. run the tests again.
|
||||||
|
- **Where conflicts are likely:**
|
||||||
|
- `gui/qt/dialogs.py`, `window.py`, `lists.py`, `plugin.py`, `widgets.py` and `core/will.py`: all were changed on `main` on 2026-09-04, 09-14 and 09-19, and commit `fb88d75` alone changed ≈ 1,800 lines of `dialogs.py`;
|
||||||
|
- `CHANGELOG.md`: both sides append at the end, so keep both entries and renumber.
|
||||||
|
- **ZIP-FIRST for every phase:**
|
||||||
|
1. build the ZIP;
|
||||||
|
2. the owner (and Truman) test it;
|
||||||
|
3. only after the owner's explicit OK, commit on `feature/i18n` and push to `origin/feature/i18n` (who does this: see D3).
|
||||||
|
- Add one numbered `CHANGELOG.md` entry per phase, in English.
|
||||||
|
- Run ruff and the tests before every ZIP (HANDOFF.md). The known pre-existing test failures are listed in HANDOFF.md, section 7, step 3.
|
||||||
|
- **At the end:** open a Pull Request `feature/i18n` → `main` on Gitea (there is a precedent: `feature/bal-qr-transfer`). Release from `main` only after the merge.
|
||||||
|
|
||||||
|
## 4. Design (decided)
|
||||||
|
|
||||||
|
**Chosen:** a gettext catalog owned by BAL, domain `bal`, shipped inside the plugin ZIP, that follows Electrum's language and is consulted **after** Electrum's own catalog (D7).
|
||||||
|
|
||||||
|
**Rejected:**
|
||||||
|
- keep using `electrum.i18n._`: 89 % of the text stays English;
|
||||||
|
- Python dictionaries or JSON files: a home-made format with no tooling;
|
||||||
|
- Qt Linguist (`.ts` / `.qm`): Electrum desktop does not use it, and `core/` has no Qt.
|
||||||
|
|
||||||
|
### 4.1 New module `bal/i18n.py`
|
||||||
|
|
||||||
|
| Function | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `_(msg)` | 1. `""` returns `""`. 2. **Electrum first (D7):** if `electrum.i18n._(msg)` differs from `msg`, return it (Electrum has already applied its own `{}` check). 3. Otherwise, if a BAL catalog is loaded and translates `msg`, apply the `{}` safety check and return the translation. The check uses the same logic as Electrum's decorator: **copy it** with an attribution comment (MIT); do not import Electrum's private function. 4. Otherwise return `msg` (English). The CLI and English behave exactly as in Electrum, because no BAL catalog is loaded there. |
|
||||||
|
| `N_(msg)` | Returns `msg` unchanged. It marks strings defined in tables, constants and class attributes, which are translated later with `_()` when displayed. Babel extracts `N_` by default. |
|
||||||
|
| `set_language(plugin, lang)` | `None`, `""` or `en_*`: no catalog. Otherwise read `locale/<lang>/LC_MESSAGES/bal.mo` with `plugin.read_file()`, falling back to `locale/<lang[:2]>/…`, and build `gettext.GNUTranslations(io.BytesIO(data))`. If the catalog is missing or broken, log at info level and continue in English. Never raise. |
|
||||||
|
| `init_from_config(plugin, config)` | `lang = config.LOCALIZATION_LANGUAGE`. If it is empty, use `electrum.gui.default_lang.get_default_language(gui_name="qt")`, guarding against `ImportError` as `run_electrum` does. Then call `set_language()`. |
|
||||||
|
|
||||||
|
- **Why Electrum first (D7):** BAL follows Electrum's wording wherever Electrum has it. If Electrum ever ships BAL as an internal plugin, Electrum's own translations (Crowdin) would be used automatically and the BAL catalog would only fill the gaps.
|
||||||
|
- **Trade-off of D7:** for a text that Electrum already translates, the BAL catalog cannot override Electrum's wording, not even with the D1 glossary. If one such case reads badly in BAL, handle that single case with the owner.
|
||||||
|
- A text that Electrum "translates" into the identical English text counts as not found and falls through to the BAL catalog. This is harmless: the BAL catalog is prefilled with Electrum's translations (3.3).
|
||||||
|
- **Do not use `gettext.translation(localedir=…)`.** When pointed inside a ZIP it silently returns `NullTranslations` (verified).
|
||||||
|
- `plugin.read_file()` is the same API BAL already uses for its icons. It works both for the ZIP install and for the dev symlink install (`AGENTS.md`).
|
||||||
|
- The state is module-level and set once. Changing the language requires restarting Electrum, as in Electrum itself.
|
||||||
|
|
||||||
|
### 4.2 Where the language is set
|
||||||
|
|
||||||
|
- In `bal/gui/qt/plugin.py`, `Plugin.__init__` (L67): call `init_from_config(self, config)` first.
|
||||||
|
- The CLI entry point (`bal/cli/plugin.py`) does not call it, so the CLI stays English.
|
||||||
|
- **Consequence:** `gui/qt/plugin.py` imports `dialogs`, `widgets` and `window` at module level (L49–51), and those import `lists`. So every class body is evaluated *before* `Plugin.__init__` runs, and any `_()` evaluated at import time would stay English. The cases in Appendix D must be converted in Phase 2.
|
||||||
|
|
||||||
|
### 4.3 Files and tools
|
||||||
|
|
||||||
|
```
|
||||||
|
babel.cfg # extraction mapping (repo root)
|
||||||
|
bal/locale/bal.pot # template, generated
|
||||||
|
bal/locale/it_IT/LC_MESSAGES/bal.po # Italian translations (source of truth)
|
||||||
|
bal/locale/it_IT/LC_MESSAGES/bal.mo # compiled (see D4)
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool is **Babel** (`pip install babel`). It is pure Python and works on Windows without GNU gettext. These commands were tested on this code base:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# babel.cfg
|
||||||
|
[ignore: bal/cli/**]
|
||||||
|
[ignore: bal/wallet_util/**]
|
||||||
|
[python: bal/**.py]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p bal/locale # extract does not create it
|
||||||
|
pybabel extract -F babel.cfg --no-wrap -o bal/locale/bal.pot .
|
||||||
|
pybabel init -i bal/locale/bal.pot -d bal/locale -D bal -l it_IT # once
|
||||||
|
pybabel update -i bal/locale/bal.pot -d bal/locale -D bal --no-wrap # after each extract
|
||||||
|
pybabel compile -d bal/locale -D bal --statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
- Today this extracts 323 strings (≈ 1,900 words) from the GUI and core.
|
||||||
|
- After Phase 2, expect about 360 strings (≈ 2,500 words).
|
||||||
|
- Poedit (free) can be used to translate or review the `.po` file by hand.
|
||||||
|
|
||||||
|
### 4.4 Translation safety (as in Electrum)
|
||||||
|
|
||||||
|
- Add a check script (for example `scripts/check_translations.py`) and run it before every ZIP. It must check that:
|
||||||
|
- every `.po` file compiles;
|
||||||
|
- the `{}` fields are identical to the source (the same rule as the runtime check);
|
||||||
|
- the `$tokens` are identical (`$wallet_name`, `$heirs_complete`, …): Electrum does not check these;
|
||||||
|
- no `msgstr` contains a Bitcoin address, an e-mail address, a URL or a long letter-and-digit word (use the regexes from `electrum-locale/update.py`), unless the same text is in the `msgid`.
|
||||||
|
|
||||||
|
It must also print the counts of translated, untranslated and fuzzy strings.
|
||||||
|
- Never put addresses or URLs inside translatable strings; pass them with `{}`.
|
||||||
|
- The owner reviews every Italian string, especially the warnings about invalidation, locktime, fees and signing.
|
||||||
|
|
||||||
|
## 5. Phases
|
||||||
|
|
||||||
|
Each phase follows HANDOFF.md: DISCOVER → PLAN (owner's "OK") → EXECUTE → VERIFY → ZIP → test by the owner and Truman → commit on `feature/i18n`.
|
||||||
|
Phases 1 and 2 change nothing visible, so they may be delivered as a single ZIP/test cycle (see D5).
|
||||||
|
|
||||||
|
### Phase 1 — Infrastructure (no visible change)
|
||||||
|
|
||||||
|
- NEW `bal/i18n.py` (section 4.1), with docstrings explaining *why* (rule R2).
|
||||||
|
- `bal/gui/qt/common.py:55`: replace `from electrum.i18n import _` with an import of `_` and `N_` from `bal.i18n`. Use a relative import, zip-safe like the existing `from ...core…` imports.
|
||||||
|
- `bal/gui/qt/theme.py:90,108`: change the local imports to `bal.i18n`.
|
||||||
|
- `bal/core/will.py:32` and `bal/core/willexecutors.py:25`: import from `bal.i18n`.
|
||||||
|
- `bal/cli/controller.py:29`: **unchanged** (the CLI stays English by design).
|
||||||
|
- `bal/gui/qt/plugin.py`, `Plugin.__init__`: call `init_from_config(self, config)`.
|
||||||
|
- NEW `tests/test_i18n.py`, covering:
|
||||||
|
- the empty string;
|
||||||
|
- Electrum's translation wins; the BAL catalog is used only when Electrum has none (D7);
|
||||||
|
- rejection of a `{}` mismatch;
|
||||||
|
- `en_UK` means no catalog;
|
||||||
|
- a missing `.mo` does not crash;
|
||||||
|
- a catalog loaded from bytes (use a small test catalog).
|
||||||
|
- **Verify:**
|
||||||
|
- the existing tests (baseline as in HANDOFF.md, section 7);
|
||||||
|
- `tests/smoke_test.py`;
|
||||||
|
- `build_zip.py` followed by `tests/external_zip_test.py`;
|
||||||
|
- manually on Windows 11 with Electrum 4.8.0, once in Italian and once in English: the UI must look exactly as before.
|
||||||
|
|
||||||
|
### Phase 2 — String clean-up and language-neutral stored data (no visible change)
|
||||||
|
|
||||||
|
- **2.0** Merge `origin/main` first.
|
||||||
|
- **2.1** Appendix A: turn `_(f"…")` and `_("…".format(x))` into `_("… {}").format(x)`. Include `cli/controller.py`, for lint consistency only.
|
||||||
|
- **2.2** Appendix C: turn each concatenation into one string with `{}`.
|
||||||
|
- Keep HTML outside the strings where possible, e.g. `"<b>{}</b>".format(_("Support:"))`.
|
||||||
|
- Fix the malformed `_("<b>Willexecutor:</b:")` (`widgets.py:1366`).
|
||||||
|
- **2.3** Appendix B: mark the literal with `N_()` where it is defined, or translate it at the call site.
|
||||||
|
- The helpers `add_widget()` (`common.py:180`), `get_window_title()` (`plugin.py:1085`) and `qlabel()` (`widgets.py:1319`) need ONE rule: callers pass text that is already translated, and the helper does not call `_()` again.
|
||||||
|
- Today `window.py:290` translates twice.
|
||||||
|
- **2.4** Appendix D: replace class-level `_()` with `N_()` and translate when the text is displayed.
|
||||||
|
- In `lists.py`, `update_headers(self.__class__.headers)` (L248, 647, 1094) must receive a translated copy.
|
||||||
|
- For the wizard `title` / `message` (`dialogs.py` L380, 419–420, 527–528, 547–548), use `_(self.title)` and `_(self.message)` at L325 and L327.
|
||||||
|
- **2.5** Appendix E: replace `_("")` with `""`.
|
||||||
|
- **2.6** Appendix F and G: make the settings help texts and labels in `plugin.py` translatable.
|
||||||
|
- **2.7** Appendix H, plus a full sweep for any other text shown to the user.
|
||||||
|
- **2.8** **Stored status** (`core/will.py:1344`):
|
||||||
|
- store the **English** label (mark the `STATUS_DEFAULT` labels with `N_()`); no `_()` at write time;
|
||||||
|
- add a helper `format_status_history(status) -> str` that splits on `.`, translates the tokens that are known English labels (with an optional `NOT ` prefix), and leaves unknown or legacy tokens unchanged (`New`, `Firmato`, …);
|
||||||
|
- use it wherever the status is shown (`lists.py:589`, `widgets.py:1336`);
|
||||||
|
- this stays backward compatible: existing wallets keep displaying correctly, and older BAL versions still read plain English;
|
||||||
|
- test it with the real legacy strings in `tests/samanta7`.
|
||||||
|
- **2.9** **Default texts** (`core/plugin_base.py`: `HISTORY_LABEL`, `EVENT_DESCRIPTION`, `EVENT_SUMMARY`):
|
||||||
|
- mark the English defaults with `N_()`;
|
||||||
|
- when the stored value is missing **or equal to the English default**, use the translated default at the moment the text is used (calendar export, history label);
|
||||||
|
- the tokens must survive translation.
|
||||||
|
- **Changed during Phase 2 (safety):** `HISTORY_LABEL` stays English and is *not* translatable. `Util._label_matches_history()` uses it to recognise BAL's own local transactions (stale-history cleanup, spendable UTXOs), so a label that changed with the GUI language would no longer match the transactions saved before. Only `EVENT_SUMMARY` and `EVENT_DESCRIPTION` follow the language (`BalConfig(..., translatable=True)`). The same rule applies to every stored or compared text (wallet labels, `heirs.TRANSACTION_LABEL`, the status history).
|
||||||
|
- **2.10** `pyproject.toml`: add `"INT"` to `[tool.ruff.lint] select`.
|
||||||
|
- Ruff's hint suggests `%` formatting: **do not follow it**. Use `.format()`, which is Electrum's rule.
|
||||||
|
- `make-release.sh` treats Ruff errors as blocking, so INT must end up clean.
|
||||||
|
- **Verify:**
|
||||||
|
- Ruff INT is clean;
|
||||||
|
- the extraction count is about 360 (compare with 4.3);
|
||||||
|
- the tests pass, plus new tests for 2.8 and 2.9;
|
||||||
|
- manually in English: no visible text changed.
|
||||||
|
|
||||||
|
### Phase 3 — Italian catalog (visible change)
|
||||||
|
|
||||||
|
- **3.0** Merge `origin/main` first.
|
||||||
|
- **3.1** Add `babel.cfg` and the commands from 4.3, documented in `README.md` or `AGENTS.md`.
|
||||||
|
- **3.2** Generate `bal/locale/bal.pot` and create `it_IT/LC_MESSAGES/bal.po`.
|
||||||
|
- **3.3** Draft the translation using the glossary (D1); the owner reviews it (D2). Where Electrum's `it_IT` catalog already translates the exact same English text, copy Electrum's translation, so the two UIs match.
|
||||||
|
- **3.4** `build_zip.py`:
|
||||||
|
- compile `.po` into `.mo`, according to D4;
|
||||||
|
- put only the `.mo` files in the ZIP (no `.po` / `.pot`);
|
||||||
|
- run the check script (4.4) before zipping.
|
||||||
|
- **Verify:**
|
||||||
|
- Electrum in Italian shows BAL in Italian;
|
||||||
|
- Electrum in English shows BAL in English;
|
||||||
|
- missing entries fall back to English;
|
||||||
|
- `external_zip_test.py` passes;
|
||||||
|
- it works on Windows 11 with Electrum 4.8.0 (and 4.7.2);
|
||||||
|
- manual walkthrough of: wizard, settings, heirs list, will list and detail, will-executors, check / sign / broadcast, invalidate, export / import, QR transfer, calendar export.
|
||||||
|
|
||||||
|
### Phase 4 — Final test and merge
|
||||||
|
|
||||||
|
- The owner and Truman test the final branch ZIP.
|
||||||
|
- Merge `origin/main` into the branch one last time and test again.
|
||||||
|
- Open the Pull Request `feature/i18n` → `main` on Gitea. After the merge, follow the normal release flow from `main`.
|
||||||
|
|
||||||
|
## 6. Decisions
|
||||||
|
|
||||||
|
Recorded on 2026-09-26 from the owner's answers.
|
||||||
|
|
||||||
|
- **D1 — Italian glossary.** Rule: follow Electrum's `it_IT` wording wherever Electrum already has the term or the sentence. Grammatical variants (nouns, past participles, plurals, capitals in headers and buttons) are adapted in the draft and checked by the owner during the review (D2).
|
||||||
|
|
||||||
|
| English | Italian |
|
||||||
|
|---|---|
|
||||||
|
| will | testamento |
|
||||||
|
| heir / heirs | erede / eredi |
|
||||||
|
| will-executor | Will-Executor (kept in English) |
|
||||||
|
| check-alive | verifica se sei vivo |
|
||||||
|
| invalidate | invalida il piano |
|
||||||
|
| invalidation (noun) | invalidazione del piano |
|
||||||
|
| anticipate / postpone | anticipa / posticipa |
|
||||||
|
| locktime | locktime (kept in English; owner, 2026-09-26) |
|
||||||
|
| fee | commissione (as Electrum) |
|
||||||
|
| broadcast | trasmetti il piano |
|
||||||
|
| sign / signed | firma / firmato (as Electrum) |
|
||||||
|
| wallet | portafogli (as Electrum) |
|
||||||
|
|
||||||
|
- **D2 — Who translates.** The developer drafts `it_IT/bal.po`; the owner reviews and corrects it (for example in Poedit). For later languages: Weblate (supports Gitea and `.po`) or Crowdin, as Electrum does.
|
||||||
|
- **D3 — Branch and commits.** The developer creates `feature/i18n`, and commits and pushes on it, each time only after the owner's explicit OK (ZIP-FIRST).
|
||||||
|
- **D4 — `.mo` files.** `build_zip.py` compiles `.po` into `.mo` at build time with Babel; `.mo` files stay out of git (add `*.mo` to `.gitignore`). If Babel is missing, the build stops with a clear error, so an English-only ZIP is never shipped by mistake. Reasons: the `.po` is the only source (no stale `.mo`), every change to a translation is readable in review, and it is how Electrum works.
|
||||||
|
- **D5 — Cycles.** Phases 1 and 2 are delivered in one ZIP/test cycle. Phase 1 is not merged into `main` early.
|
||||||
|
- **D6 — Branch name:** `feature/i18n`.
|
||||||
|
- **D7 — Lookup order.** Electrum's catalog first, then BAL's, then English (see 4.1). Proposed by the owner so that BAL and Electrum read as one, also if Electrum ever integrates BAL.
|
||||||
|
|
||||||
|
## 7. Reminders for the developer
|
||||||
|
|
||||||
|
- Talk to the owner in **Italian**, with simple words and no jargon (the owner is not a programmer). Code, comments, docstrings, UI source strings, docs and commits are in **English**.
|
||||||
|
- UI source strings stay in English; Italian lives only in the `.po` file.
|
||||||
|
- Never invent. When in doubt, stop and ask.
|
||||||
|
- Before writing any code, present the phase PLAN and wait for "OK".
|
||||||
|
- ZIP-FIRST; run ruff and the tests before every ZIP; one CHANGELOG entry per phase; keep reports brief (CREDIT-SAVING).
|
||||||
|
- Wallet data compatibility comes first: apart from 2.8 (status labels), do not change what is stored.
|
||||||
|
|
||||||
|
## Appendix — Inventory at `main` @ `4a33116`
|
||||||
|
|
||||||
|
Generated by a static scan (AST, Ruff `INT` and xgettext). The line numbers will move after merging a newer `main`: re-run the scan or search by content. These lists cover the known cases; Phase 2 still needs a full sweep.
|
||||||
|
|
||||||
|
#### A. f-string / `.format()` inside `_()` — Ruff INT001/INT002 (14)
|
||||||
|
|
||||||
|
| Location | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `bal/cli/controller.py:92` | INT001 |
|
||||||
|
| `bal/cli/controller.py:98` | INT001 |
|
||||||
|
| `bal/cli/controller.py:104` | INT001 |
|
||||||
|
| `bal/gui/qt/common.py:225` | INT002 |
|
||||||
|
| `bal/gui/qt/common.py:240` | INT002 |
|
||||||
|
| `bal/gui/qt/dialogs.py:1226` | INT001 |
|
||||||
|
| `bal/gui/qt/dialogs.py:2491` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:831` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:836` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:1090` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:1091` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:2228` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:2236` | INT001 |
|
||||||
|
| `bal/gui/qt/window.py:2246` | INT001 |
|
||||||
|
|
||||||
|
#### B. `_()` with a non-literal argument (not extractable) (15)
|
||||||
|
|
||||||
|
| Location | Code |
|
||||||
|
|---|---|
|
||||||
|
| `bal/core/will.py:1344` | `_(self.STATUS[status][0])` |
|
||||||
|
| `bal/gui/qt/common.py:181` | `_(label)` |
|
||||||
|
| `bal/gui/qt/dialogs.py:327` | `_(self.message)` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2288` | `_(msg)` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2291` | `_(msg)` |
|
||||||
|
| `bal/gui/qt/lists.py:1240` | `_(label)` |
|
||||||
|
| `bal/gui/qt/plugin.py:1086` | `_(title)` |
|
||||||
|
| `bal/gui/qt/widgets.py:284` | `_(self.tooltip_text)` |
|
||||||
|
| `bal/gui/qt/widgets.py:1320` | `_(str(title))` |
|
||||||
|
| `bal/gui/qt/window.py:290` | `_(title)` |
|
||||||
|
| `bal/gui/qt/window.py:942` | `_(message)` |
|
||||||
|
| `bal/gui/qt/window.py:2217` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||||
|
| `bal/gui/qt/window.py:2219` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||||
|
| `bal/gui/qt/window.py:2253` | `_(self.DOWNLOAD_FAILED_TOR_MESSAGE)` |
|
||||||
|
| `bal/gui/qt/window.py:2255` | `_(self.DOWNLOAD_FAILED_MESSAGE)` |
|
||||||
|
|
||||||
|
#### C. Concatenations around `_()` (14)
|
||||||
|
|
||||||
|
| Location | Expression |
|
||||||
|
|---|---|
|
||||||
|
| `bal/core/will.py:1344` | `'.' + (('NOT ' if not value else '') + _(self.STATUS[status][0]))` |
|
||||||
|
| `bal/gui/qt/dialogs.py:1010` | `_('Wallet balance is too low: {} satoshi available, but the miner and…` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2206` | `messages[reason] + '\n\n' + _('Skipped')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2209` | `_("Could not build the will, and the exact cause could not be determi…` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2419` | `_('Expiration date: ') + str(BalTimestamp(self.threshold))` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2425` | `_('Valid Txs:') + str(len(Will.only_valid_list(self.will)))` |
|
||||||
|
| `bal/gui/qt/dialogs.py:2427` | `_('Total Txs:') + str(len(self.will))` |
|
||||||
|
| `bal/gui/qt/lists.py:679` | `' ' + _('Build Your Will')` |
|
||||||
|
| `bal/gui/qt/plugin.py:301` | `'Bal ' + _('Bitcoin After Life')` |
|
||||||
|
| `bal/gui/qt/plugin.py:1034` | `'<b>' + _('Support:') + '</b>'` |
|
||||||
|
| `bal/gui/qt/plugin.py:1086` | `_('BAL - ') + _(title)` |
|
||||||
|
| `bal/gui/qt/widgets.py:1320` | `'<b>' + _(str(title)) + f':</b>\t{str(value)}'` |
|
||||||
|
| `bal/gui/qt/window.py:1022` | `_('Electrum was unable to deserialize the transaction:') + '\n' + str…` |
|
||||||
|
| `bal/gui/qt/window.py:1091` | `msg + _(f'signing: {tosign}')` |
|
||||||
|
|
||||||
|
#### D. `_()` evaluated at import time (class body) (18)
|
||||||
|
|
||||||
|
| Location | Scope | Call |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/dialogs.py:419` | class body | `_('Bitcoin After Life Will-Executors')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:420` | class body | `_('Choose willexecutors download method')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:528` | class body | `_('Configure and select your willexecutors')` |
|
||||||
|
| `bal/gui/qt/dialogs.py:548` | class body | `_('')` |
|
||||||
|
| `bal/gui/qt/lists.py:131` | class body | `_('Name')` |
|
||||||
|
| `bal/gui/qt/lists.py:132` | class body | `_('Address')` |
|
||||||
|
| `bal/gui/qt/lists.py:133` | class body | `_('Amount')` |
|
||||||
|
| `bal/gui/qt/lists.py:340` | class body | `_('Locktime')` |
|
||||||
|
| `bal/gui/qt/lists.py:341` | class body | `_('Txid')` |
|
||||||
|
| `bal/gui/qt/lists.py:342` | class body | `_('Will-Executor')` |
|
||||||
|
| `bal/gui/qt/lists.py:343` | class body | `_('Status')` |
|
||||||
|
| `bal/gui/qt/lists.py:344` | class body | `_('Server')` |
|
||||||
|
| `bal/gui/qt/lists.py:907` | class body | `_('')` |
|
||||||
|
| `bal/gui/qt/lists.py:908` | class body | `_('Url')` |
|
||||||
|
| `bal/gui/qt/lists.py:909` | class body | `_('S')` |
|
||||||
|
| `bal/gui/qt/lists.py:910` | class body | `_('Base fee')` |
|
||||||
|
| `bal/gui/qt/lists.py:911` | class body | `_('Info')` |
|
||||||
|
| `bal/gui/qt/lists.py:912` | class body | `_('Default Address')` |
|
||||||
|
|
||||||
|
#### E. `_("")` (2)
|
||||||
|
|
||||||
|
| Location | Call |
|
||||||
|
|---|---|
|
||||||
|
| `bal/gui/qt/dialogs.py:548` | `_('')` |
|
||||||
|
| `bal/gui/qt/lists.py:907` | `_('')` |
|
||||||
|
|
||||||
|
#### F. Settings help texts never passed to `_()` (12)
|
||||||
|
|
||||||
|
| Location | Widget | Text |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/plugin.py:715` | HelpButton | When checking, automatically sign and broadcast the will transactions… |
|
||||||
|
| `bal/gui/qt/plugin.py:778` | HelpButton | `How many reminder alarms the exported calendar (.ics) event contains.…` |
|
||||||
|
| `bal/gui/qt/plugin.py:789` | HelpButton | `Default message to be used in event summary\nVariables:\n $wallet_na…` |
|
||||||
|
| `bal/gui/qt/plugin.py:803` | HelpButton | `Default message to be used in event description\nVariables:\n $walle…` |
|
||||||
|
| `bal/gui/qt/plugin.py:818` | HelpButton | URL of the server that provides the will-executor list. Only availab… |
|
||||||
|
| `bal/gui/qt/plugin.py:829` | HelpButton | `Command used to open .ics calendar files.\nLeave empty to use the sys…` |
|
||||||
|
| `bal/gui/qt/plugin.py:844` | HelpButton | `After each check, save the valid will transactions into the wallet's …` |
|
||||||
|
| `bal/gui/qt/plugin.py:858` | HelpButton | `Label applied to the will transactions saved into the wallet's local …` |
|
||||||
|
| `bal/gui/qt/plugin.py:880` | HelpButton | Broadcast all transactions to willexecutors including those already p… |
|
||||||
|
| `bal/gui/qt/plugin.py:891` | HelpButton | `Run the 'Build your will' wizard every time the wallet is closed or E…` |
|
||||||
|
| `bal/gui/qt/plugin.py:909` | HelpButton | When a new transaction arrives for the wallet, automatically rebuild … |
|
||||||
|
| `bal/gui/qt/plugin.py:933` | HelpButton | `Payload size of a single QR code when exporting a will via QR.\n\nLar…` |
|
||||||
|
|
||||||
|
#### G. `add_widget()` calls (label translated at runtime, help text never) (5)
|
||||||
|
|
||||||
|
| Location | Label | Help text |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/plugin.py:697` | Hide Replaced | Hide replaced transactions from will detail and l… |
|
||||||
|
| `bal/gui/qt/plugin.py:705` | Hide Invalidated | Hide invalidated transactions from will detail an… |
|
||||||
|
| `bal/gui/qt/plugin.py:727` | Panel editable Date and Fee | When enabled, the delivery-time and check-alive d… |
|
||||||
|
| `bal/gui/qt/plugin.py:742` | `Max Will-Executor Fee (satoshi)` | `Maximum fee (in satoshi) allowed to be paid to a …` |
|
||||||
|
| `bal/gui/qt/plugin.py:757` | User Type | `Choose how much detail the plugin shows.\n\nBASIC…` |
|
||||||
|
|
||||||
|
#### H. Other user-visible text not (correctly) translatable — known cases, not exhaustive
|
||||||
|
|
||||||
|
| Location | Code | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `bal/gui/qt/dialogs.py:380` | `title = "Bitcoin After Life Heirs"` | Wizard title, shown untranslated at `dialogs.py:325` |
|
||||||
|
| `bal/gui/qt/dialogs.py:527` | `title = "Bitcoin After Life Will-Executors"` | Same |
|
||||||
|
| `bal/gui/qt/dialogs.py:547` | `title = "Bitcoin After Life Will Settings"` | Same |
|
||||||
|
| `bal/gui/qt/lists.py:1472` | `setText("New Will Executor")` | Not wrapped |
|
||||||
|
| `bal/gui/qt/plugin.py:673` | `QPushButton("Rebroadcast transactions")` | Not wrapped |
|
||||||
|
| `bal/gui/qt/widgets.py:1323` | `qlabel("Locktime", …)` | Translated inside `qlabel()` but not extractable |
|
||||||
|
| `bal/gui/qt/widgets.py:1324` | `qlabel("Creation Time", …)` | Same |
|
||||||
|
| `bal/gui/qt/widgets.py:1334` | `qlabel("Transaction fees:", …)` | Same; `qlabel()` adds another `:` |
|
||||||
|
| `bal/gui/qt/widgets.py:1336` | `qlabel("Status:", …)` | Same; also shows the stored status (see 2.8) |
|
||||||
|
| `bal/gui/qt/widgets.py:1339` | `QLabel("<b>Heirs:</b>")` | Not wrapped |
|
||||||
|
| `bal/gui/qt/widgets.py:1366` | `_("<b>Willexecutor:</b:")` | Malformed HTML inside the string |
|
||||||
|
| `bal/gui/qt/window.py:1613` | `msg = "Broadcasting Transactions to Will-Executors:\n"` | Not wrapped |
|
||||||
|
|
||||||
|
Not to translate: `widgets.py:122` (icon glyph), `plugin.py:1020` (link to bitcoin-after.life).
|
||||||
|
|
||||||
|
#### Numbers (`main` @ `4a33116`)
|
||||||
|
|
||||||
|
| Measure | Value |
|
||||||
|
|---|---|
|
||||||
|
| `_()` calls | 483 in 11 files |
|
||||||
|
| Distinct strings, whole package (xgettext) | 365 (≈ 2,230 words) |
|
||||||
|
| …of which Electrum `it_IT` already translates | 39 (≈ 11 %) |
|
||||||
|
| Strings used only by the CLI | 36 (≈ 315 words), out of scope |
|
||||||
|
| Babel extraction today, GUI + core | 323 (≈ 1,900 words) |
|
||||||
|
| Expected after Phase 2 | ≈ 360 (≈ 2,500 words) |
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Electrum 4.8.0: `electrum/i18n.py`, `run_electrum` (L433–451), `electrum/gui/default_lang.py`, `electrum/plugin.py` (`maybe_load_plugin_init_method`, `read_file`), `contrib/locale/` — https://github.com/spesmilo/electrum/tree/4.8.0
|
||||||
|
- `electrum-locale` at the 4.8.0 submodule commit `3d7594c`: `update.py`, `llm_proofreader/` — https://github.com/spesmilo/electrum-locale
|
||||||
|
- Gitea "Update branch" button on pull requests — https://github.com/go-gitea/gitea/pull/9784
|
||||||
|
- Weblate: Gitea and gettext PO support — https://docs.weblate.org/en/latest/vcs.html
|
||||||
499
PLAN_QR_TRANSFER.md
Normal file
499
PLAN_QR_TRANSFER.md
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
# PLAN — Will transfer via QR codes / audio modem (Qt now, QML planned)
|
||||||
|
|
||||||
|
> Goal: let the user move an inheritance ("will") between devices over two
|
||||||
|
> air-gap channels:
|
||||||
|
>
|
||||||
|
> 1. **QR codes** (primary): export the **valid** inheritance transactions as
|
||||||
|
> a sequence of QR codes, and import them back on another machine with the
|
||||||
|
> camera;
|
||||||
|
> 2. **Audio modem** (secondary, when Electrum's `audio_modem` plugin is
|
||||||
|
> enabled): send/receive the same payload through the PC speaker +
|
||||||
|
> microphone.
|
||||||
|
>
|
||||||
|
> Both channels converge on the same review-and-sign flow afterwards.
|
||||||
|
>
|
||||||
|
> Status: APPROVED by owner (2026-08-25). No code written yet — this document
|
||||||
|
> is the implementation contract. Work top-down through §9 Checklist.
|
||||||
|
>
|
||||||
|
> Chat language: Italian; this document is in English (global rule R1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **Desktop PyQt6 GUI** (primary, implemented by this plan):
|
||||||
|
- New plugin setting: QR chunk size, offered as **4 standard presets**.
|
||||||
|
- *"Export via QR"* action: serializes the **valid** will transactions,
|
||||||
|
optionally compresses, splits the resulting string into fixed-size frames,
|
||||||
|
shows one QR at a time with prev/next navigation, live re-chunking and
|
||||||
|
progress (`i di N`).
|
||||||
|
- *"Import via QR"* action: camera capture dialog with a slot grid
|
||||||
|
(1..N); the user selects which shot he is about to capture, scans, the
|
||||||
|
frame lands in its slot; when 1..N are filled the payload is assembled,
|
||||||
|
parsed into `WillItem`s, validity-checked locally.
|
||||||
|
- **Post-capture flow (owner decision D6, amended)**: after capture
|
||||||
|
completes there is NO read-only preview. Instead a review-and-sign wizard
|
||||||
|
walks through every transaction one at a time showing **outputs
|
||||||
|
(address + amount), total outputs, total fees**, signs it (wallet
|
||||||
|
password asked once), and at the end **proposes exporting the signed
|
||||||
|
transactions** (to file and/or back via QR).
|
||||||
|
- **Audio-modem channel** (owner decision D7): when the Electrum
|
||||||
|
`audio_modem` plugin is enabled and available, the export dialog gains a
|
||||||
|
*"Send via Audio Modem…"* button and the import dialog a *"Receive via
|
||||||
|
Audio Modem…"* button, reusing the same transfer string (no QR framing).
|
||||||
|
- **QML**: document-only update to `QML_PLAN.md` adding dedicated view specs
|
||||||
|
(owner decision D1). No QML code in this feature.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Implementing the QML frontend (gated behind `QML_PLAN.md` Phases 0–2).
|
||||||
|
- Merging imported wills into the live wallet state (existing Merge flows
|
||||||
|
stay unchanged).
|
||||||
|
- Broadcast of the reviewed transactions (user exports them; broadcasting
|
||||||
|
remains an explicit action elsewhere).
|
||||||
|
- CLI/cmdline parity for QR transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Owner decisions (locked)
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|----------|
|
||||||
|
| D1 | QML part = update `QML_PLAN.md` document only; implementation later. |
|
||||||
|
| D2 | Frames carry a small **compact** ASCII header (`BAL1<total><index><flag>`, fixed 11 chars, base36 count fields) — import knows the total, auto-fills the grid, detects corrupt/duplicate/mismatched frames. Pure concatenation rejected. Legacy `BALQR1\|N\|i\|flags\|` export removed; legacy import kept. |
|
||||||
|
| D3 | Payload = **serialized transaction strings only** (`str(wi.tx)`), NOT the JSON will dump. Loses statuses/metadata on purpose; import rebuilds items like `merge_single_transaction` does. |
|
||||||
|
| D4 | Compression (zlib+base64 over the whole payload) exported as **best-of**: `encode_transfer_best` ships compressed when it is shorter, plain otherwise; flag `0` = plain, `Z` = compressed. No user-facing checkbox. |
|
||||||
|
| D5 | 4 standard size presets: **~150 / ~400 / ~900 / ~1800 bytes** of payload per QR (low-res cams → high-res cams). Error-correction level fixed **M**. Stored as plugin config default; selectable again inside the export dialog. |
|
||||||
|
| D6 | After capture completes: **review + sign each tx one at a time** (show outputs, total outputs, total fees), then **propose export of the signed txs** (file and/or QR). Supersedes the earlier "WillDetailDialog preview" answer. |
|
||||||
|
| D7 | Add an **audio-modem transfer path** gated on Electrum's `audio_modem` plugin being enabled and available (`amodem` importable). Same payload semantics as QR (transfer string of serialized txs), but NO BAL frame chunking — `amodem` handles transport framing internally. Buttons simply hidden when the plugin is absent/unavailable (info message pointing at `pip install amodem` when enabled-but-broken); graceful degradation, never a hard dependency. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verified facts (research done on the local checkouts)
|
||||||
|
|
||||||
|
All verified by reading source; references are `file:line`.
|
||||||
|
|
||||||
|
| # | Fact | Where |
|
||||||
|
|---|------|-------|
|
||||||
|
| F1 | Export today: `BalWindow.export_will()` writes `{wid: WillItem.to_dict()}` JSON; subsets All/Valid/Valid-NC built in `WillList` | `bal/gui/qt/window.py:1605-1621`, `lists.py:666-669, 743-767` |
|
||||||
|
| F2 | Batch signer: `BalWindow.sign_transactions(password, will, txids)` loops valid txs, resolves input values from change prevouts (`txin._trusted_value_sats` …), calls `wallet.sign_transaction`, updates `COMPLETE` + signature counts | `window.py:1017-1086` |
|
||||||
|
| F3 | External-will signing already supported (`will=` param, nothing saved to live wallet/history) | `window.py:1443-1484` |
|
||||||
|
| F4 | Single-tx import precedent: `merge_single_transaction` wraps `WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=...)` | `window.py:1715-1724` |
|
||||||
|
| F5 | Local validity recomputation recipe (no network): `add_willtree` → `Util.get_available_utxos` → `check_invalidated` → `search_rai` → `check_signatures` | `window.py:1678-1701` |
|
||||||
|
| F6 | Plugin config accessor pattern `BalConfig`; keys declared in ctor | `bal/core/plugin_base.py:130-154, 211-264` |
|
||||||
|
| F7 | Plugin settings dialog: grid rows 0..12, `add_widget(grid,label,widget,row,help)` + `_make_reset_btn(cfgvar,widget,kind)`; ADVANCED-only rows wrapped with `_hide_if_basic(...)` | `bal/gui/qt/plugin.py:442-850` (rows at 677-850) |
|
||||||
|
| F8 | `BalDialog(parent, bal_plugin, title=None, icon=...)` base class anchors to top-level window | `bal/gui/qt/dialogs.py:92-129` |
|
||||||
|
| F9 | Fee display precedent: `fee = tx.input_value() - tx.output_value()`, fee rate = `fee / tx.estimated_size()` | `bal/gui/qt/widgets.py:1319-1328` |
|
||||||
|
| F10 | Input-value resolution helper exists: `Will.add_info_from_will(will, wid, wallet)` sets trusted input values from sibling will change outputs | `bal/core/will.py:118-136` |
|
||||||
|
| F11 | `str(tx)` = `tx.serialize()`: raw hex for complete txs; `PartialTransaction.serialize()` → base64 PSBT. Both accepted by `tx_from_any` (= `Will.get_tx_from_any`). This is exactly how BAL persists/reloads txs today | `electrum/transaction.py:907, 2539`; `will.py:106-113`; `window.py:1041-1044` |
|
||||||
|
| F12 | `QRCodeWidget` exists but **hardcodes `ERROR_CORRECT_L`** → cannot satisfy D5/M; must render our own `qrcode` instance | `electrum/gui/qt/qrcodewidget.py:35-37` |
|
||||||
|
| F13 | Camera scanning one-shot API with OS-permission handling: `scan_qrcode_from_camera(*, parent, config, callback(success: bool, error: str, data: Optional[str]))`; on Linux uses zbar CLI backend | `electrum/gui/qt/qrreader/__init__.py:47-64` |
|
||||||
|
| F14 | QR painting without PIL: `draw_qr(qr, paint_device, ...)` from `electrum.gui.common_qt.util` (what `QRCodeWidget.paintEvent` uses) | `electrum/gui/qt/qrcodewidget.py:63-72` |
|
||||||
|
| F15 | QR capacity sanity (byte mode, EC **M**): v40-M ≈ 2331 B ≥ 1800 ✓; v10-M ≈ 213 B ≥ 150 ✓; the `qrcode` lib auto-picks the version | `qrcode` lib |
|
||||||
|
| F16 | `build_zip.py` walks the tree with `os.walk` → new `.py` files ship automatically | `build_zip.py:39-47` |
|
||||||
|
| F17 | QML fork already has `QRImage.qml`, `QRScan.qml`, `ScanDialog.qml`; ScanDialog carries upstream comment "currently not used on android … qt6 camera support stops crashing" | `electrum/gui/qml/components/ScanDialog.qml:8-9` |
|
||||||
|
| F18 | `QML_PLAN.md` currently defers chunked multi-QR streams (Phase 3 note + risk R6) — this feature supersedes that deferral | `QML_PLAN.md:228-231, 304` |
|
||||||
|
| F19 | House test conventions: `def test_*` + `if __name__ == "__main__"` + `sys.path.insert(0, ..pardir)`; run standalone or via pytest | `tests/test_core_heirs.py:1-24` |
|
||||||
|
| F20 | Fork ships an `audio_modem` plugin. `_send(parent, blob)` zlib-compresses an **ASCII** blob, plays it via speaker through `amodem` inside a `WaitingDialog`; bit-rate selectable in the plugin's own settings (default = `amodem.config.slowest()`) | `electrum/plugins/audio_modem/qt.py:96-110` |
|
||||||
|
| F21 | `_recv(parent)` records from mic and delivers the decompressed ASCII text by calling `parent.setText(blob)` — the only integration contract is "an object with `setText(str)`"; there is no callback API | `audio_modem/qt.py:112-127` |
|
||||||
|
| F22 | Plugin lookup for enabled plugins: `window.plugins.get(name)` → instance or `None` (`Plugins.get`, electrum/plugin.py:575-576); availability check is the plugin's own `is_available()` (imports `amodem`). **`amodem` is NOT installed in the runtime env today** → optional dependency (pip `amodem` + libportaudio); feature must degrade gracefully | `electrum/plugin.py:575`, runtime-env check |
|
||||||
|
| F23 | `amodem.main.send/recv` stream the whole blob with their own framing/training → BAL must NOT apply QR frame chunking on this channel; and since `_send` compresses internally, BAL sends the **plain** transfer string to avoid double compression | consequence of F20/F21 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Wire format specification
|
||||||
|
|
||||||
|
### 4.1 Transfer string
|
||||||
|
|
||||||
|
```
|
||||||
|
transfer_string = "\n".join( tx_str(tx) for tx in valid_txs_sorted_by_txid )
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tx_str(tx)` = `str(tx)` (F11): hex for complete txs, base64-PSBT for
|
||||||
|
partially-signed ones. Neither alphabet contains `\n` or `|`, so both are
|
||||||
|
safe delimiters.
|
||||||
|
- Ordering: ascending `tx.txid()` → deterministic output for identical input.
|
||||||
|
- If compression enabled (D4):
|
||||||
|
`transfer_string = base64_ascii( zlib_compress( transfer_string ) )`.
|
||||||
|
|
||||||
|
### 4.2 Frame layout (one frame = content of ONE QR code)
|
||||||
|
|
||||||
|
Wire format v2 (compact, current export):
|
||||||
|
|
||||||
|
```
|
||||||
|
BAL1<TTT><iii><F><payload>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Magic+version literal `BAL1` (reject anything else with a clear message).
|
||||||
|
- `<TTT>` = `<iii>` — **base36** zero-padded 3-char strings (`000`…`ZZZ`),
|
||||||
|
representing total N and index i, `1 ≤ i ≤ N ≤ 46655`. Fixed width means a
|
||||||
|
3-digit count field costs the same for a 1-frame or a 46655-frame transfer.
|
||||||
|
- `<F>`: single flag char — `0` ⇒ plain, `Z` ⇒ zlib+base64 compressed.
|
||||||
|
- `<payload>`: the i-th slice of `transfer_string`, exactly
|
||||||
|
`chunk_size` bytes each (last slice may be shorter). No separators: both
|
||||||
|
base36 count fields are fixed-width, so the header is unambiguously 11
|
||||||
|
chars and the payload starts at offset 11.
|
||||||
|
- Header overhead is a constant **11 bytes** → effective payload =
|
||||||
|
`chunk_size − 11`; the chunker slices the transfer string so that
|
||||||
|
**header+payload ≤ preset size**.
|
||||||
|
|
||||||
|
Legacy frames `BALQR1|<total>|<index>|<flags>|<payload>` (variable-width
|
||||||
|
decimal header, pipe-separated) are still **imported** by `parse_frame`
|
||||||
|
(`_parse_v1`), so old exports keep working; the exporter emits v2 only.
|
||||||
|
That is the one deliberate compatibility break: **v2 frames are not readable
|
||||||
|
by builds older than this change.**
|
||||||
|
|
||||||
|
### 4.3 Size presets (D5)
|
||||||
|
|
||||||
|
| Preset label | Payload budget (bytes/frame) | Typical QR version @EC-M |
|
||||||
|
|--------------|------------------------------|--------------------------|
|
||||||
|
| Small (low-res cameras) | 150 | ~v10 |
|
||||||
|
| Medium | 400 | ~v15 |
|
||||||
|
| Large | 900 | ~v22 |
|
||||||
|
| XL (high-res cameras) | 1800 | ~v40 |
|
||||||
|
|
||||||
|
EC level fixed **M** for scan reliability (D5). Presets live in
|
||||||
|
`bal/core/qrtransfer.py::CHUNK_PRESETS` so core tests can cover them.
|
||||||
|
|
||||||
|
### 4.4 Audio-modem channel (D7, F20-F23)
|
||||||
|
|
||||||
|
- Payload = the **plain** `transfer_string` of §4.1 — no BAL frames
|
||||||
|
(`split_frames`/`parse_frame` are QR-only), no BAL compression (the plugin
|
||||||
|
compresses internally; double compression wastes airtime).
|
||||||
|
- The existing core functions `encode_transfer(tx_strings,
|
||||||
|
compress=False)` + `decode_transfer(text, compressed=False)` are reused
|
||||||
|
unchanged; only the transport differs.
|
||||||
|
- Bit-rate is owned by the audio_modem plugin's settings dialog — BAL adds
|
||||||
|
no setting of its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. New core module — `bal/core/qrtransfer.py`
|
||||||
|
|
||||||
|
GUI-free (never imports Qt — house rule). Public API:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MAGIC = "BALQR"
|
||||||
|
VERSION = 1
|
||||||
|
FLAG_COMPRESSED = "Z"
|
||||||
|
CHUNK_PRESETS = [(label_en, budget_bytes), ...] # §4.3 table
|
||||||
|
|
||||||
|
def encode_transfer(tx_strings: list[str], compress: bool = False) -> str
|
||||||
|
"""Join -> optional zlib+base64 -> return transfer_string."""
|
||||||
|
|
||||||
|
def split_frames(transfer_string: str, chunk_size: int) -> list[str]
|
||||||
|
"""Slice into frames 'BAL1<TTT><iii><F>payload' (v2) — header+payload <=
|
||||||
|
chunk_size. Raises QrTransferError over the 46655-frame base36 cap, or
|
||||||
|
ValueError if chunk_size < MIN_CHUNK_SIZE."""
|
||||||
|
|
||||||
|
def encode_transfer_best(tx_strings: list[str]) -> tuple[str, bool]
|
||||||
|
"""-> (transfer_string, compressed); ships the shorter of plain vs
|
||||||
|
zlib+base64 so the export emits the densest frames."""
|
||||||
|
|
||||||
|
def parse_frame(frame: str) -> tuple[int, int, bool, str]
|
||||||
|
"""-> (total, index, compressed, payload); accepts v2 'BAL1…' and legacy
|
||||||
|
'BALQR1|…' (wrapped as _parse_v1/_parse_v2); ValueError on bad magic/
|
||||||
|
version/arity/non-numeric fields."""
|
||||||
|
|
||||||
|
def assemble(frames: dict[int, str]) -> str
|
||||||
|
"""Validate indices form exactly range(1..max_total) (taken from any
|
||||||
|
frame header), concatenate payloads in order, decode flags ->
|
||||||
|
transfer_string. Raises MissingFramesError(indexes) / InconsistentTotalError."""
|
||||||
|
|
||||||
|
def decode_transfer(transfer_string: str, compressed: bool) -> list[str]
|
||||||
|
"""Inverse of encode_transfer -> list of tx strings."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus exceptions `QrTransferError(ValueError)`, `MissingFramesError`,
|
||||||
|
`InconsistentTotalError`. All docstrings/comments English; ruff-clean
|
||||||
|
(line-length 88, E501 ignored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Settings (Qt)
|
||||||
|
|
||||||
|
1. `bal/core/plugin_base.py`: after `REBUILD_ON_CLOSE` (~line 264) add
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. `bal/gui/qt/plugin.py::settings_dialog` (rows end at 12, ~line 844):
|
||||||
|
append row **13** — visible in BASIC and ADVANCED (do NOT wrap with
|
||||||
|
`_hide_if_basic`):
|
||||||
|
|
||||||
|
- Label: `"QR Code Size"`
|
||||||
|
- `QComboBox` fed from `CHUNK_PRESETS`; item text e.g.
|
||||||
|
`"Small — ~150 bytes/QR (low-res cameras)"`; `currentIndexChanged`
|
||||||
|
→ `self.QR_CHUNK_SIZE.set(budget_bytes)`; initial index from
|
||||||
|
`QR_CHUNK_SIZE.get()` (fallback to nearest preset if the stored value
|
||||||
|
was customized).
|
||||||
|
- `HelpButton` text: explains trade-off (small QR = more shots but easier
|
||||||
|
to scan with poor cameras; large QR = fewer shots, needs good camera)
|
||||||
|
and that the size can also be changed inside the export dialog.
|
||||||
|
- Reset button via existing `_make_reset_btn(self.QR_CHUNK_SIZE, combo, ...)`
|
||||||
|
pattern (plugin.py:684).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Qt export flow
|
||||||
|
|
||||||
|
### 7.1 Entry point
|
||||||
|
|
||||||
|
`bal/gui/qt/lists.py::WillList.create_toolbar` (menu block lines 666-670):
|
||||||
|
|
||||||
|
```python
|
||||||
|
export_menu.addAction(_("Via QR…"), self.export_will_valid_qr)
|
||||||
|
```
|
||||||
|
|
||||||
|
New `WillList.export_will_valid_qr()` mirrors `export_will_valid`
|
||||||
|
(lists.py:743-754): builds `{wid: wi}` subset of `VALID` items, empty →
|
||||||
|
`show_message(_("No valid will item to export"))`, else
|
||||||
|
`self.bal_window.export_will_via_qr(will=subset)`.
|
||||||
|
|
||||||
|
### 7.2 `BalWindow.export_will_via_qr(will=None)` (new, `window.py` near
|
||||||
|
`export_will`)
|
||||||
|
|
||||||
|
- Collect `tx_strings = [str(wi.tx) for wid, wi in sorted-by-txid ...]`
|
||||||
|
(F11).
|
||||||
|
- Mark exported items `EXPORTED` (parity with `export_json_file`,
|
||||||
|
window.py:1607-1609) — only when `will` came from the live list.
|
||||||
|
- Open `WillQrExportDialog(self, tx_strings)`.
|
||||||
|
|
||||||
|
Shared helper used by both dialogs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_audio_modem_plugin(self): # on BalWindow
|
||||||
|
"""Return the loaded audio_modem plugin if enabled AND available
|
||||||
|
(amodem importable), else None. Never raises."""
|
||||||
|
p = self.window.plugins.get("audio_modem") # F22
|
||||||
|
return p if p is not None and p.is_available() else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 `WillQrExportDialog(BalDialog)` (new class in `dialogs.py`)
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Size ▾ Small/Medium/Large/XL] ← compressed best-of automatically (no checkbox)
|
||||||
|
[ QR image ] ← BalQrImage (see below)
|
||||||
|
«i di N» [◀ Prev] [Next ▶]
|
||||||
|
[Save current QR as PNG…] [Send via Audio Modem…] [Close]
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- On any control change: rebuild `split_frames(encode_transfer_best(...))`,
|
||||||
|
reset index to frame 1, refresh counter (owner requirement: "cambiare la
|
||||||
|
risoluzione").
|
||||||
|
- `BalQrImage(QWidget)` ≈ trimmed copy of `QRCodeWidget`
|
||||||
|
(`electrum/gui/qt/qrcodewidget.py:21-72`) but constructing
|
||||||
|
`qrcode.QRCode(error_correction=ERROR_CORRECT_M, border=2)` and painting
|
||||||
|
via `electrum.gui.common_qt.util.draw_qr` (F12/F14). ~30 lines.
|
||||||
|
- Prev/Next wrap or disable at ends (disable chosen: clearer).
|
||||||
|
- PNG export optional convenience via existing
|
||||||
|
`getSaveFileName` + `QWidget.grab()` (same trick as
|
||||||
|
`qrcodewidget.py:110`).
|
||||||
|
- **Send via Audio Modem…** (D7): shown only when
|
||||||
|
`bal_window.get_audio_modem_plugin()` returns a usable instance (below);
|
||||||
|
otherwise hidden. Handler: re-encode the payload **plain**
|
||||||
|
(`encode_transfer(tx_strings, compress=False)`) and call the plugin's
|
||||||
|
`_send(parent=self, blob=transfer_string)` — its own WaitingDialog owns
|
||||||
|
progress/cancellation (F20). Tooltip when hidden is unnecessary; instead,
|
||||||
|
if the plugin is enabled but `is_available()` is False, show an info
|
||||||
|
message pointing to `pip install amodem` + portaudio (F22).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Qt import flow + review/sign wizard
|
||||||
|
|
||||||
|
### 8.1 Entry point
|
||||||
|
|
||||||
|
`lists.py` toolbar menu, next to Import/Merge (lines 670-671):
|
||||||
|
|
||||||
|
```python
|
||||||
|
menu.addAction(_("Import via QR…"), lambda: self.bal_window.import_will_via_qr())
|
||||||
|
```
|
||||||
|
|
||||||
|
`BalWindow.import_will_via_qr()` opens `WillQrImportDialog(self)`.
|
||||||
|
|
||||||
|
### 8.2 `WillQrImportDialog(BalDialog)`
|
||||||
|
|
||||||
|
State: `self.frames: dict[int, str]`, `self.total: int | None`,
|
||||||
|
`self.target_index: int | None`.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
«Captured k of N» [Scan ▶] [Reset]
|
||||||
|
[slot grid: push-buttons 1..N; states: empty / filled ✓ / selected-target]
|
||||||
|
hint line («Select a slot, then scan» / «Scan the first QR»)
|
||||||
|
[Receive via Audio Modem…] [Review & Sign ▶] [Close]
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
|
||||||
|
- **Scan** → `scan_qrcode_from_camera(parent=self,
|
||||||
|
config=self.bal_window.window.config, callback=self._on_scan)`
|
||||||
|
(F13). One-shot per press; dialog stays open between shots (simplest,
|
||||||
|
matches Electrum UX; no continuous mode).
|
||||||
|
- `_on_scan(success, error, data)`:
|
||||||
|
- failure → `show_error(error)` (covers missing zbar/camera too);
|
||||||
|
- `parse_frame` errors → `show_warning(_("Not a BAL will QR"))`;
|
||||||
|
- first valid frame adopts `total` and materializes the slot grid;
|
||||||
|
- frame whose `total` ≠ adopted total → warn + offer Reset (user may have
|
||||||
|
restarted the export with another size);
|
||||||
|
- valid → `frames[index] = payload`; auto-advance `target_index` to the
|
||||||
|
lowest missing index; refresh grid + counter.
|
||||||
|
- Clicking an empty slot sets `target_index` (owner requirement: manual
|
||||||
|
shot selection); a filled slot click asks to overwrite.
|
||||||
|
- **Receive via Audio Modem…** (D7): shown only when
|
||||||
|
`bal_window.get_audio_modem_plugin()` returns a usable instance. Handler:
|
||||||
|
build a tiny adapter object exposing `setText(str)` that stores the text
|
||||||
|
and invokes the shared post-receive continuation, then call
|
||||||
|
`plugin._recv(parent=self, ...)`-style flow (F21 contract). On success the
|
||||||
|
received string is treated as the **whole payload**: skip frames/slots
|
||||||
|
entirely → `decode_transfer(text, compressed=False)` → continue at §8.2's
|
||||||
|
item-building step (WillItem construction + validity pass + wizard).
|
||||||
|
Errors from the modem surface through the plugin's own dialog; empty
|
||||||
|
result (user cancelled) is silently ignored.
|
||||||
|
- **Review & Sign** enabled only when `set(frames) == set(range(1, N+1))`:
|
||||||
|
runs `assemble` + `decode_transfer` → `list[str]`; any `QrTransferError`
|
||||||
|
surfaces as `show_error` and keeps the dialog open.
|
||||||
|
- Build items exactly like `merge_single_transaction` (F4):
|
||||||
|
`WillItem({"tx": s}, wallet=self.wallet)` per string; failures per-string
|
||||||
|
are collected and reported at the end (bad string ≠ fatal for the rest).
|
||||||
|
- Local validity pass (F5 recipe) on the resulting dict; items failing
|
||||||
|
`VALID` are dropped and listed in a warning. Set
|
||||||
|
`wi.set_status("IMPORTED", True)` on survivors (mirrors
|
||||||
|
`import_will_into_details`, window.py:1753-1754).
|
||||||
|
- Then `close()` and start the wizard (§8.3) with the valid subset. Empty
|
||||||
|
result → stop with a message.
|
||||||
|
|
||||||
|
### 8.3 `WillTxReviewSignDialog(BalDialog)` — post-capture wizard (D6)
|
||||||
|
|
||||||
|
Constructed with `(bal_window, willitems: dict[str, WillItem])` — the
|
||||||
|
imported subset lives **outside** the live wallet state (external mode,
|
||||||
|
F3).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
1. **Password once**: `password = bal_window.get_wallet_password()`
|
||||||
|
(window.py:1088-1100). Returns `False` on cancel → abort wizard; `None`
|
||||||
|
means unencrypted wallet → proceed without password.
|
||||||
|
2. **Per-transaction page** (one `QStackedWidget` step per tx, ordered by
|
||||||
|
txid like export):
|
||||||
|
|
||||||
|
```
|
||||||
|
Tx 2 of 5 — a1b2…c3d1 (short txid)
|
||||||
|
Locktime: 2033-04-05 Status: unsigned (0/1 sigs)
|
||||||
|
┌ outputs ─────────────────────────────────┐
|
||||||
|
│ bc1q…heir1 0,042 BTC │
|
||||||
|
│ bc1q…willexec fee 0,00012 BTC │
|
||||||
|
│ bc1q…change 0,00988 BTC │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
Total outputs: 0,052 BTC Fees: 420 sat (1.2 sat/vB)
|
||||||
|
[Sign & Next ▶] [Skip] [Cancel all]
|
||||||
|
```
|
||||||
|
- Outputs from `tx.outputs()` (address via `TxOutput.get_ui_address_str()`
|
||||||
|
style helpers already imported in the qt layer; value via
|
||||||
|
`bal_window.window.format_amount`).
|
||||||
|
- Totals: `output_value()` sum; fees via `input_value() - output_value()`
|
||||||
|
after resolving inputs with `Will.add_info_from_will(will, wid, wallet)`
|
||||||
|
(F10); `-1`/unknown handled like widgets.py:1319-1324 (F9).
|
||||||
|
3. **Sign & Next** → sign this single tx through a **refactored helper**
|
||||||
|
extracted from the loop body of `sign_transactions`
|
||||||
|
(window.py:1037-1083 → `_sign_single_tx(tx, willitems, password)` kept
|
||||||
|
byte-equivalent; batch method calls the helper per iteration so existing
|
||||||
|
behaviour/tests are unaffected). Update `COMPLETE`/sig-counts exactly as
|
||||||
|
today; then advance.
|
||||||
|
4. **Skip** leaves the tx untouched and advances. **Cancel all** stops; the
|
||||||
|
already-signed txs remain in the wizard's local dict (still exportable —
|
||||||
|
confirmation dialog warns about skipped ones).
|
||||||
|
5. **Summary page**: `signed X of Y`, skipped/failed lists, then:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Save signed file…] [Show QR…] [Close]
|
||||||
|
```
|
||||||
|
- *Save file* = existing JSON path: `export_meta_gui(window,
|
||||||
|
"will.json", writer)` writing `{wid: wi.to_dict()}` of the signed
|
||||||
|
subset (same serializer as `export_json_file`, window.py:1605).
|
||||||
|
- *Show QR* = `WillQrExportDialog` over `[str(wi.tx)]` of the signed
|
||||||
|
subset (the online machine can scan them straight into Merge).
|
||||||
|
- Nothing touches `self.willitems`/history (external-mode rule, F3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist (execution order — tick here when resuming work)
|
||||||
|
|
||||||
|
- [x] **P0** `bal/core/qrtransfer.py` + unit tests `tests/test_core_qr_transfer.py`
|
||||||
|
(cases: round-trip plain/compressed; boundaries: len%size==0, size>len,
|
||||||
|
min-size guard; bad magic/version; missing middle frame; duplicate
|
||||||
|
overwrite; inconsistent totals; multi-PSBT mixes; presets sanity vs
|
||||||
|
qrcode capacities F15). Run:
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/test_core_qr_transfer.py`
|
||||||
|
- [x] **P1** Settings: `QR_CHUNK_SIZE` config var + settings-dialog row 16
|
||||||
|
(ø16) + reset kind (§6). Verify in `QT_QPA_PLATFORM=offscreen` GUI run.
|
||||||
|
- [x] **P2** Export: `BalWindow.export_will_via_qr`, `get_audio_modem_plugin`
|
||||||
|
helper, `WillList` menu action, `WillQrExportDialog` + `BalQrImage`,
|
||||||
|
audio-modem send button (§7).
|
||||||
|
- [x] **P3** Import: `import_will_via_qr`, `WillQrImportDialog` (§8.2),
|
||||||
|
incl. camera error paths, audio-modem receive button (local mirror of
|
||||||
|
`_recv`, `setText` sink replaced by a callback), plain-payload fast
|
||||||
|
path into the wizard.
|
||||||
|
- [x] **P4** Wizard: `_prepare_and_sign_tx` refactor + `WillTxReviewSignDialog`
|
||||||
|
(§8.3). Regression-gate: full batch sign still green
|
||||||
|
(`tests/test_core_*.py` offline batch; `tests/test_gui_*.py` batch
|
||||||
|
including new `tests/test_gui_qr_transfer.py`).
|
||||||
|
- [x] **P5** Docs & QML plan sync: update `QML_PLAN.md` — Phase 2 models +=
|
||||||
|
`BalQrTransferModel` (thin QObject over `bal.core.qrtransfer`),
|
||||||
|
Phase 3 += dedicated views `BalQrExportPage.qml` /
|
||||||
|
`BalQrImportPage.qml` (slot grid + `QRScan` reuse), delete the
|
||||||
|
"chunked streams deferred" note, rewrite R6 mitigation, add Android
|
||||||
|
caveat quoting F17 with file/paste fallback; README/HANDOFF sections;
|
||||||
|
CHANGELOG numbered entry 56 at END (house rule).
|
||||||
|
- [x] **P6** Release hygiene: `python3 build_zip.py` +
|
||||||
|
`QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py
|
||||||
|
electrum.plugins.bal` + external-zip test; ruff (repo venv
|
||||||
|
`venv/bin/ruff`) no NEW violations; pyright false-positive policy per
|
||||||
|
AGENTS.md. Version bump only via `make-release.sh` (owner-driven).
|
||||||
|
Docs: document audio-modem as OPTIONAL channel — requires the
|
||||||
|
Electrum `audio_modem` plugin enabled plus `pip install amodem`
|
||||||
|
and libportaudio (not installed in the dev runtime env today, F22);
|
||||||
|
manual test matrix gains an audiomodem round-trip row (two machines,
|
||||||
|
default slowest bitrate) marked optional/skippable when hardware
|
||||||
|
unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| High frame counts annoy users (e.g. 40+ QR at 150 B) | Presets span 150→1800; compress option; counter always visible |
|
||||||
|
| Big QR versions fail on cheap cameras | EC=M fixed; Small preset targets low-res cams (D5 rationale) |
|
||||||
|
| User rescans old export with different total | `InconsistentTotalError` → clear warning + Reset (§8.2) |
|
||||||
|
| `_sign_single_tx` refactor regresses batch signing | Byte-equivalent extraction; batch callers unchanged; offline core tests gate P4 |
|
||||||
|
| Imported txs reference UTXOs the importing wallet doesn't know | Validity pass drops them with an explicit report instead of silently merging garbage |
|
||||||
|
| zbar/camera unavailable (esp. Windows/macOS packaging) | `scan_qrcode_from_camera` error path → suggest file export/import fallback |
|
||||||
|
| `amodem`/portaudio not installed (current dev env state, F22) or audio_modem plugin disabled | Buttons simply hidden; QR/file remain the primary channels; P6 documents the optional dependency |
|
||||||
|
| Audio transfer fails mid-way (noise, wrong volume) | Plugin's WaitingDialog surfaces the error; user retries — nothing to clean up on BAL side (single atomic blob, no slot state touched) |
|
||||||
|
| Very slow airtime at default slowest bitrate | Bitrate is selectable in the audio_modem plugin's own settings (F20); BAL adds no knob; tooltip in export dialog hints at large payloads |
|
||||||
|
| Qt6 camera instability on Android (future QML work) | Recorded as caveat in QML_PLAN update (P5), file/paste stays the primary mobile fallback |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Findings log (append-only)
|
||||||
|
|
||||||
|
- 2026-08-25: plan drafted after code exploration; owner answered D1-D6
|
||||||
|
(D6 amended live from "preview dialog" to "review+sign wizard").
|
||||||
|
- Verified F12 (QRCodeWidget hardcodes EC-L) and F11 (str(tx) round-trip
|
||||||
|
guarantees) — both shaped §§4/7.
|
||||||
|
- 2026-08-25: owner requested an audio-modem transfer path → researched
|
||||||
|
`electrum/plugins/audio_modem/qt.py`, added D7 + F20-F23, §4.4, buttons
|
||||||
|
in §§7.3/8.2, checklist/risk updates. Key constraint found: `_recv`'s
|
||||||
|
only contract is `parent.setText(blob)` (F21) → thin adapter object; and
|
||||||
|
BAL must not chunk/compress on this channel (F23). `amodem` is NOT in
|
||||||
|
the runtime env yet — feature is strictly optional.
|
||||||
119
README.md
119
README.md
@@ -5,10 +5,11 @@ Free and decentralized **Bitcoin inheritance** support for the
|
|||||||
that transfer your funds to your heirs if you stop refreshing them
|
that transfer your funds to your heirs if you stop refreshing them
|
||||||
(dead-man's switch), optionally relayed by will-executor servers.
|
(dead-man's switch), optionally relayed by will-executor servers.
|
||||||
|
|
||||||
This repository contains a **behavior-preserving refactor** of the original
|
This repository contains a **refactored and extended** version of the original
|
||||||
plugin. The logic was kept byte-identical wherever possible; only the file
|
plugin. The logic was reorganized to cleanly separate **business logic** from the
|
||||||
layout was reorganized to cleanly separate **business logic** from the
|
**PyQt GUI**, and new features have been added including a headless CLI,
|
||||||
**PyQt GUI**.
|
auto-rebuild on new transactions, OP_RETURN heirs, and configurable calendar
|
||||||
|
reminders.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -16,12 +17,22 @@ layout was reorganized to cleanly separate **business logic** from the
|
|||||||
bal/ the installable Electrum plugin package
|
bal/ the installable Electrum plugin package
|
||||||
├── manifest.json plugin metadata (Electrum reads this)
|
├── manifest.json plugin metadata (Electrum reads this)
|
||||||
├── qt.py Qt entry-point shim (re-exports Plugin)
|
├── qt.py Qt entry-point shim (re-exports Plugin)
|
||||||
|
├── cmdline.py CLI entry-point shim (re-exports Plugin)
|
||||||
├── core/ GUI-free logic (importable without Qt)
|
├── core/ GUI-free logic (importable without Qt)
|
||||||
│ ├── util.py
|
│ ├── util.py
|
||||||
│ ├── plugin_base.py
|
│ ├── plugin_base.py
|
||||||
│ ├── heirs.py
|
│ ├── heirs.py
|
||||||
│ ├── will.py
|
│ ├── will.py
|
||||||
│ └── willexecutors.py
|
│ ├── willexecutors.py
|
||||||
|
│ ├── checkalive.py
|
||||||
|
│ ├── reminders.py
|
||||||
|
│ ├── qrtransfer.py transfer-string codec / QR size presets
|
||||||
|
│ ├── animated_qr.py BBQR codec (stdlib-only)
|
||||||
|
│ └── input_rules.py
|
||||||
|
├── cli/ headless command-line layer (no Qt)
|
||||||
|
│ ├── commands.py bal_* daemon commands (@plugin_command)
|
||||||
|
│ ├── controller.py headless BalController (replicates BalWindow)
|
||||||
|
│ └── plugin.py CLI Plugin entry point
|
||||||
├── gui/qt/ PyQt6 presentation layer
|
├── gui/qt/ PyQt6 presentation layer
|
||||||
│ ├── theme.py status → color mapping
|
│ ├── theme.py status → color mapping
|
||||||
│ ├── common.py shared imports / helpers
|
│ ├── common.py shared imports / helpers
|
||||||
@@ -30,18 +41,28 @@ bal/ the installable Electrum plugin package
|
|||||||
│ ├── dialogs.py dialog windows
|
│ ├── dialogs.py dialog windows
|
||||||
│ ├── lists.py tree/list views
|
│ ├── lists.py tree/list views
|
||||||
│ ├── window.py per-wallet GUI controller
|
│ ├── window.py per-wallet GUI controller
|
||||||
|
│ ├── window_utils.py GUI utility helpers
|
||||||
│ └── plugin.py Plugin (Electrum @hooks → GUI)
|
│ └── plugin.py Plugin (Electrum @hooks → GUI)
|
||||||
├── icons/ wallet_util/ LICENSE VERSION README.md
|
├── icons/ wallet_util/ LICENSE README.md
|
||||||
build_zip.py builds a clean, zipimport-friendly distribution zip
|
build_zip.py builds a clean, zipimport-friendly distribution zip
|
||||||
tests/ smoke + external-zip regression tests
|
tests/ smoke + external-zip regression tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Electrum 4.7.2** — the last stable release exposing `json_db.register_dict`,
|
- **Electrum 4.7.2 or 4.8.0** — the plugin detects which wallet-DB
|
||||||
which this plugin relies on. Newer versions removed it.
|
registration API is available (`json_db.register_dict` on 4.7.2,
|
||||||
|
`stored_dict.register_name` on 4.8.0) and adapts automatically.
|
||||||
- **PyQt6** (bundled with the Electrum desktop GUI).
|
- **PyQt6** (bundled with the Electrum desktop GUI).
|
||||||
|
|
||||||
|
## Wallet compatibility
|
||||||
|
|
||||||
|
BAL currently supports **standard (single-signature) wallets** and
|
||||||
|
**hardware wallets** supported by Electrum. **Multisig wallets** and
|
||||||
|
**Electrum TrustedCoin (2FA) wallets** are **not yet supported** — see
|
||||||
|
[`COMPATIBILITY.md`](COMPATIBILITY.md) for the full compatibility matrix and
|
||||||
|
current status.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Build the distribution archive
|
### Build the distribution archive
|
||||||
@@ -68,6 +89,62 @@ Copy the `bal/` directory into your Electrum installation's
|
|||||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||||
exists, then enable it from **Tools → Plugins**.
|
exists, then enable it from **Tools → Plugins**.
|
||||||
|
|
||||||
|
## Transfer a will with QR codes (or audio)
|
||||||
|
|
||||||
|
From the will list (**Export → QR Codes**) a will can be exported as a
|
||||||
|
sequence of QR codes and imported on another device (**Import via QR**). The
|
||||||
|
export offers All / Valid / Valid-NC filters plus a QR size preset
|
||||||
|
(150–1800 bytes/frame) and ships the **BBQR** format; the import
|
||||||
|
flow reviews and sign each transaction
|
||||||
|
one at a time, then proposes exporting the signed transactions. When
|
||||||
|
Electrum's `audio_modem` plugin is enabled (optional, requires `amodem` +
|
||||||
|
PortAudio) Send/Receive audio buttons complement the QR channel. See
|
||||||
|
[`PLAN_QR_TRANSFER.md`](PLAN_QR_TRANSFER.md) for the transfer wire-format spec.
|
||||||
|
|
||||||
|
### Animated-QR format (BBQR)
|
||||||
|
|
||||||
|
The export page emits **BBQR** (`B$…`, Coinkite, used by BitKit and Coldcard)
|
||||||
|
animated-QR sequences. The importer auto-detects each code it sees, so a BAL
|
||||||
|
export can be imported by any tool that understands BBQR. BBQR frames may
|
||||||
|
arrive in any order, and duplicates are ignored. The 32 MB message limit
|
||||||
|
(zlib-bomb guard) bounds untrusted scanner input.
|
||||||
|
|
||||||
|
## Command-line / headless usage
|
||||||
|
|
||||||
|
BAL can be used without the Qt GUI via Electrum's daemon mode. The CLI layer
|
||||||
|
exposes `bal_*` commands that replicate the full inheritance cycle.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- An **Electrum daemon** running (`electrum daemon -d`)
|
||||||
|
- A wallet loaded (`electrum load_wallet`)
|
||||||
|
|
||||||
|
### Available commands
|
||||||
|
|
||||||
|
| Category | Commands |
|
||||||
|
|----------|----------|
|
||||||
|
| Settings | `bal_settings_list`, `bal_settings_get`, `bal_settings_set`, `bal_settings_reset` |
|
||||||
|
| Heirs | `bal_heirs_list`, `bal_heirs_show`, `bal_heirs_add`, `bal_heirs_update`, `bal_heirs_delete`, `bal_heirs_import`, `bal_heirs_export` |
|
||||||
|
| Will-Executors | `bal_willexecutors_list`, `bal_willexecutors_show`, `bal_willexecutors_add`, `bal_willexecutors_update`, `bal_willexecutors_select`, `bal_willexecutors_delete`, `bal_willexecutors_ping`, `bal_willexecutors_download`, `bal_willexecutors_import`, `bal_willexecutors_export` |
|
||||||
|
| Will | `bal_will_status`, `bal_will_check`, `bal_will_prepare`, `bal_will_autorebuild`, `bal_will_sign`, `bal_will_broadcast`, `bal_will_export`, `bal_will_import_merge`, `bal_will_invalidate`, `bal_will_check_executor` |
|
||||||
|
|
||||||
|
### Example workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
electrum daemon -d
|
||||||
|
electrum load_wallet
|
||||||
|
electrum bal_heirs_list
|
||||||
|
electrum bal_will_prepare
|
||||||
|
electrum bal_will_sign --password '...'
|
||||||
|
electrum bal_will_broadcast
|
||||||
|
electrum stop
|
||||||
|
```
|
||||||
|
|
||||||
|
All commands require a running daemon (Electrum's `plugin_command` enforces
|
||||||
|
this). Wallet-bound commands (`bal_heirs_*`, `bal_will_*`, etc.) require the
|
||||||
|
wallet to be loaded first. Signing commands require `--password` for encrypted
|
||||||
|
wallets.
|
||||||
|
|
||||||
## Inheritance safety: anticipate / postpone
|
## Inheritance safety: anticipate / postpone
|
||||||
|
|
||||||
A will transaction is signed with a **fixed, immutable locktime** and then
|
A will transaction is signed with a **fixed, immutable locktime** and then
|
||||||
@@ -76,12 +153,17 @@ to broadcast it (they collect fees). Because the locktime is baked into the
|
|||||||
signed transaction, simply changing the delivery time later is **not enough**:
|
signed transaction, simply changing the delivery time later is **not enough**:
|
||||||
the old, already-signed transaction keeps living on the will-executors.
|
the old, already-signed transaction keeps living on the will-executors.
|
||||||
|
|
||||||
The plugin handles the two cases as follows (triggered when you press
|
The plugin handles the cases as follows (triggered when you press
|
||||||
**Tools → Prepare**):
|
**Prepare** on the **WILL** tab):
|
||||||
|
|
||||||
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
|
* **Anticipate** (new delivery time *earlier* than the signed locktime, still
|
||||||
will is treated as expired and you are asked to **invalidate** the old
|
in the future): a plain **rebuild** — the transactions are re-created with
|
||||||
transaction on-chain, then rebuild.
|
the new, earlier locktime. **No on-chain invalidation and no Bitcoin fee**,
|
||||||
|
even if the will was already signed/sent: moving the date earlier only makes
|
||||||
|
the inheritance available *sooner*, so there is no early-execution risk.
|
||||||
|
* **Expire** (new delivery time now in the **past**): the will is genuinely
|
||||||
|
expired and you are asked to **invalidate** the old transaction on-chain,
|
||||||
|
then rebuild.
|
||||||
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
||||||
that was already **signed and/or pushed**: the previously committed coins
|
that was already **signed and/or pushed**: the previously committed coins
|
||||||
must be invalidated on-chain **first**, otherwise a will-executor could
|
must be invalidated on-chain **first**, otherwise a will-executor could
|
||||||
@@ -112,14 +194,15 @@ state.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
Run the tests with the **runtime environment** active (see `HANDOFF.md` §3 for
|
||||||
|
the two venvs and how to activate them):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# imports + behavior
|
# imports + behavior
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py electrum.plugins.bal
|
||||||
python3 tests/smoke_test.py electrum.plugins.bal
|
|
||||||
|
|
||||||
# external-zip loading regression
|
# external-zip loading regression (run after build_zip.py)
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
QT_QPA_PLATFORM=offscreen python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
||||||
python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚠️ Safety
|
## ⚠️ Safety
|
||||||
|
|||||||
5
babel.cfg
Normal file
5
babel.cfg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Babel extraction mapping for the BAL gettext catalog (see bal/i18n.py).
|
||||||
|
# The CLI and the wallet_util helpers stay English, like Electrum's CLI.
|
||||||
|
[ignore: bal/cli/**]
|
||||||
|
[ignore: bal/wallet_util/**]
|
||||||
|
[python: bal/**.py]
|
||||||
@@ -12,8 +12,8 @@ servers.
|
|||||||
already-signed will is handled safely. Postponing a signed/sent will first
|
already-signed will is handled safely. Postponing a signed/sent will first
|
||||||
asks you to invalidate the old transaction on-chain (so a will-executor can
|
asks you to invalidate the old transaction on-chain (so a will-executor can
|
||||||
never broadcast the earlier-locktime transaction and execute the inheritance
|
never broadcast the earlier-locktime transaction and execute the inheritance
|
||||||
too early), then lets you rebuild and re-send the new one via
|
too early), then lets you rebuild and re-send the new one via the
|
||||||
**Tools → Prepare**.
|
**Prepare** button on the **WILL** tab.
|
||||||
- **"Server" column**: the will transaction list shows whether each transaction
|
- **"Server" column**: the will transaction list shows whether each transaction
|
||||||
is actually stored on the will-executor servers
|
is actually stored on the will-executor servers
|
||||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
0.5.18
|
|
||||||
@@ -24,11 +24,18 @@ distinct sub-packages:
|
|||||||
lists.py Tree/list views (heirs, preview, will-executors)
|
lists.py Tree/list views (heirs, preview, will-executors)
|
||||||
window.py BalWindow controller (per-wallet GUI state)
|
window.py BalWindow controller (per-wallet GUI state)
|
||||||
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
plugin.py Plugin class wiring Electrum @hooks to the GUI
|
||||||
|
cli/ Headless command-line layer (no Qt)
|
||||||
|
commands.py The @plugin_command transport layer (registers
|
||||||
|
the ``bal_*`` commands)
|
||||||
|
controller.py Headless replica of the Qt flows (later phases)
|
||||||
|
plugin.py Plugin(BalPlugin) entry point for the daemon
|
||||||
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
qt.py Thin loader shim re-exporting `Plugin` for Electrum
|
||||||
|
cmdline.py Thin loader shim re-exporting `Plugin` for the daemon
|
||||||
|
|
||||||
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
Electrum discovers the plugin through ``manifest.json`` and loads the GUI
|
||||||
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
entry point from ``qt.py`` (the shim), which imports the real ``Plugin``
|
||||||
from ``gui.qt.plugin``.
|
from ``gui.qt.plugin``; the command-line/daemon entry point is ``cmdline.py``
|
||||||
|
(the shim), which imports ``Plugin`` from ``cli.plugin``.
|
||||||
|
|
||||||
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
||||||
``json_db.register_dict`` and replaced it with the path-based
|
``json_db.register_dict`` and replaced it with the path-based
|
||||||
@@ -36,4 +43,89 @@ The plugin supports Electrum 4.7.2 and 4.8.0 with PyQt6. Electrum 4.8.0 removed
|
|||||||
available and adapts, so both releases keep working.
|
available and adapts, so both releases keep working.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.5.18"
|
# The plugin version is NOT defined here. It lives only in ``bal/manifest.json``
|
||||||
|
# (the single source of truth) and is read at runtime via ``get_version()`` in
|
||||||
|
# ``bal/core/plugin_base.py`` (exposed as the ``BalPlugin.version`` property).
|
||||||
|
# Keeping a hardcoded ``__version__`` here would just be a stale duplicate.
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI command registration
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Electrum's CLI pre-parse (run_electrum calls ``Plugins(config, cmd_only=True)``)
|
||||||
|
# only imports the plugin package ``__init__`` to discover its commands.
|
||||||
|
# Importing ``bal.cli.commands`` here registers every ``bal_*`` command with
|
||||||
|
# ``electrum.commands`` (``known_commands`` + the ``Commands`` class), so the
|
||||||
|
# commands become available on the command line and over JSON-RPC without any Qt.
|
||||||
|
#
|
||||||
|
# The import must be zip-safe: when the plugin is loaded as an external zip,
|
||||||
|
# Electrum registers the package under the synthetic name
|
||||||
|
# ``electrum_external_plugins.bal``, but the module's ``__package__`` is only
|
||||||
|
# ``bal`` (the zip-internal directory name), which is not present in
|
||||||
|
# ``sys.modules`` and cannot be used for sub-module imports. We therefore
|
||||||
|
# resolve the real package name and import through ``importlib`` (the same
|
||||||
|
# trick as ``qt.py``).
|
||||||
|
import importlib
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_package_name() -> str:
|
||||||
|
"""Return the name this package is registered under in ``sys.modules``.
|
||||||
|
|
||||||
|
Internal plugins are imported as ``electrum.plugins.bal`` (a normal import,
|
||||||
|
so ``__package__`` is already correct). External zip plugins are imported
|
||||||
|
under the synthetic name ``electrum_external_plugins.bal`` with
|
||||||
|
``__package__`` set to just the zip-internal directory name (``bal``); only
|
||||||
|
the synthetic name is present in ``sys.modules``.
|
||||||
|
"""
|
||||||
|
pkg = __package__ or "bal"
|
||||||
|
if pkg in _sys.modules:
|
||||||
|
return pkg
|
||||||
|
synthetic = "electrum_external_plugins." + __name__
|
||||||
|
if synthetic in _sys.modules:
|
||||||
|
return synthetic
|
||||||
|
return pkg
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||||
|
"""Backfill missing ancestor packages in ``sys.modules``.
|
||||||
|
|
||||||
|
When loaded from a zip as an external plugin, Electrum only executes the
|
||||||
|
package ``__init__``; the synthetic root package (``electrum_external_plugins``)
|
||||||
|
may be missing, which would break sub-module imports. We stub it out as a
|
||||||
|
namespace package so ``importlib`` can still resolve its children (same
|
||||||
|
helper as ``qt.py``).
|
||||||
|
"""
|
||||||
|
parts = pkg_name.split(".")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
ancestor = ".".join(parts[:i])
|
||||||
|
if ancestor in _sys.modules:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
importlib.import_module(ancestor)
|
||||||
|
except Exception:
|
||||||
|
import types
|
||||||
|
|
||||||
|
module = types.ModuleType(ancestor)
|
||||||
|
module.__path__ = [] # mark as a (namespace) package
|
||||||
|
_sys.modules[ancestor] = module
|
||||||
|
|
||||||
|
|
||||||
|
def _register_cli_commands() -> None:
|
||||||
|
"""Import ``bal.cli.commands`` so Electrum registers the ``bal_*`` commands.
|
||||||
|
|
||||||
|
Guarded so a dual install (internal package AND external zip) cannot
|
||||||
|
register the same command names twice, which would make
|
||||||
|
``electrum.commands.plugin_command`` raise
|
||||||
|
"Command name bal_... already exists".
|
||||||
|
"""
|
||||||
|
from electrum import commands as _electrum_commands
|
||||||
|
|
||||||
|
if getattr(_electrum_commands, "_bal_cli_commands_registered", False):
|
||||||
|
return
|
||||||
|
pkg = _resolve_package_name()
|
||||||
|
_ensure_parent_packages(pkg)
|
||||||
|
importlib.import_module(pkg + ".cli.commands")
|
||||||
|
_electrum_commands._bal_cli_commands_registered = True
|
||||||
|
|
||||||
|
|
||||||
|
_register_cli_commands()
|
||||||
|
|||||||
19
bal/cli/__init__.py
Normal file
19
bal/cli/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
bal.cli
|
||||||
|
=======
|
||||||
|
|
||||||
|
Headless command-line layer of the Bitcoin After Life (BAL) Electrum plugin.
|
||||||
|
|
||||||
|
This sub-package implements the ``"cmdline"`` front-end: it exposes the
|
||||||
|
plugin's functionality through Electrum ``bal_*`` commands while reusing only
|
||||||
|
the GUI-free logic from ``bal.core``. Like ``bal.core``, it MUST never import
|
||||||
|
PyQt or ``electrum.gui``.
|
||||||
|
|
||||||
|
* ``bal.cli.commands`` -> the ``@plugin_command`` transport layer
|
||||||
|
* ``bal.cli.controller`` -> headless replica of the Qt flows (later phases)
|
||||||
|
* ``bal.cli.plugin`` -> ``Plugin(BalPlugin)`` entry point for the daemon
|
||||||
|
|
||||||
|
Electrum discovers the plugin through ``manifest.json`` (``available_for``
|
||||||
|
includes ``"cmdline"``) and loads the entry point from ``cmdline.py``, a thin
|
||||||
|
zip-safe shim following the same pattern as ``qt.py``.
|
||||||
|
"""
|
||||||
424
bal/cli/commands.py
Normal file
424
bal/cli/commands.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
"""
|
||||||
|
bal.cli.commands
|
||||||
|
================
|
||||||
|
|
||||||
|
CLI commands (``bal_*``) for the Bitcoin After Life plugin.
|
||||||
|
|
||||||
|
This module is the *transport layer* of the command-line front-end: every
|
||||||
|
function is a coroutine decorated with ``@plugin_command`` so Electrum exposes
|
||||||
|
it as ``bal_<name>`` both on the command line and over JSON-RPC. The functions
|
||||||
|
validate their arguments and delegate the real work to
|
||||||
|
:mod:`bal.cli.controller` (a headless replica of the Qt flows); this module
|
||||||
|
never imports Qt.
|
||||||
|
|
||||||
|
It must stay lightweight: Electrum imports it during the CLI pre-parse
|
||||||
|
(``run_electrum`` calls ``Plugins(config, cmd_only=True)``) and on every
|
||||||
|
GUI/daemon startup, before any wallet or network object exists. The heavy
|
||||||
|
imports (``bal.core``, the controller) happen lazily inside each command.
|
||||||
|
|
||||||
|
Flags (see ``electrum.commands.plugin_command``):
|
||||||
|
|
||||||
|
* ``n`` -> requires a running daemon/network (always set for plugins);
|
||||||
|
* ``w`` -> resolves and injects the wallet from the daemon;
|
||||||
|
* ``p`` -> requires the wallet password (for signing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from electrum.commands import plugin_command
|
||||||
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
|
from .controller import BalController, _user_facing
|
||||||
|
|
||||||
|
plugin_name = "bal"
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(plugin, wallet):
|
||||||
|
"""Build the headless controller, or fail with a clear message."""
|
||||||
|
if plugin is None:
|
||||||
|
raise UserFacingException("the bal plugin is not enabled in this daemon")
|
||||||
|
if wallet is None:
|
||||||
|
raise UserFacingException("wallet not loaded")
|
||||||
|
return BalController(plugin, wallet)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(plugin, wallet, method, *args, **kwargs):
|
||||||
|
controller = _controller(plugin, wallet)
|
||||||
|
try:
|
||||||
|
return getattr(controller, method)(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
raise _user_facing(e) from e
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Settings
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_list(self, plugin=None):
|
||||||
|
"""List all BAL plugin configuration options (key, name and value).
|
||||||
|
|
||||||
|
Returns a JSON object mapping every BAL configuration option (``bal_*``)
|
||||||
|
to an object with ``value``, ``default`` and ``name``.
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_list")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_get(self, key, plugin=None):
|
||||||
|
"""Show the current value of one BAL configuration option.
|
||||||
|
|
||||||
|
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_get", key)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_set(self, key, value, plugin=None):
|
||||||
|
"""Set a BAL configuration option (booleans, integers, strings, JSON).
|
||||||
|
|
||||||
|
arg:str:key:The configuration key (e.g. ``bal_user_type``).
|
||||||
|
arg:str:value:The new value; JSON for object-typed keys such as ``bal_will_settings``.
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_set", key, value)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("n", plugin_name)
|
||||||
|
async def settings_reset(self, key, plugin=None):
|
||||||
|
"""Reset a BAL configuration option to its default value.
|
||||||
|
|
||||||
|
arg:str:key:The configuration key (e.g. ``bal_tx_fees``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, None, "settings_reset", key)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Heirs
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_list(self, wallet=None, plugin=None):
|
||||||
|
"""List the heirs of the current wallet.
|
||||||
|
|
||||||
|
Returns a JSON object mapping heir names to their ``[address, amount,
|
||||||
|
locktime]`` values.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_list")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_show(self, name, wallet=None, plugin=None):
|
||||||
|
"""Show the details of a single heir.
|
||||||
|
|
||||||
|
arg:str:name:The heir name.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_show", name)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_add(self, name, address, amount, locktime=None, wallet=None, plugin=None):
|
||||||
|
"""Add (or replace) an heir in the current wallet.
|
||||||
|
|
||||||
|
arg:str:name:The heir name.
|
||||||
|
arg:str:address:The destination address (or ``OP_RETURN:<hex>`` for an OP_RETURN heir).
|
||||||
|
arg:str:amount:The amount in satoshis or a percentage like ``50%%``.
|
||||||
|
arg:str:locktime:The delivery locktime (absolute timestamp or ``30d``/``1y``); defaults to the will locktime.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_add", name, address, amount, locktime)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_update(
|
||||||
|
self,
|
||||||
|
name,
|
||||||
|
address=None,
|
||||||
|
amount=None,
|
||||||
|
locktime=None,
|
||||||
|
wallet=None,
|
||||||
|
plugin=None,
|
||||||
|
):
|
||||||
|
"""Update an existing heir (only the given fields).
|
||||||
|
|
||||||
|
arg:str:name:The heir name.
|
||||||
|
arg:str:address:The new destination address.
|
||||||
|
arg:str:amount:The new amount in satoshis or a percentage.
|
||||||
|
arg:str:locktime:The new delivery locktime.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_update", name, address, amount, locktime)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_delete(self, names, wallet=None, plugin=None):
|
||||||
|
"""Delete one or more heirs.
|
||||||
|
|
||||||
|
arg:json:names:A JSON array of heir names (e.g. ``["Alice","Bob"]``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_delete", names)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_import(self, path, wallet=None, plugin=None):
|
||||||
|
"""Import heirs from a JSON file (validated, merged).
|
||||||
|
|
||||||
|
arg:str:path:Path to the JSON file.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_import", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def heirs_export(self, path, wallet=None, plugin=None):
|
||||||
|
"""Export the heirs to a JSON file.
|
||||||
|
|
||||||
|
arg:str:path:Destination file path.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "heirs_export", path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Will-Executors
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_list(self, wallet=None, plugin=None):
|
||||||
|
"""List the will-executors for the current network.
|
||||||
|
|
||||||
|
Returns a JSON object mapping executor URLs to their records (address,
|
||||||
|
base_fee, status, info, selected, ...).
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_list")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_show(self, url, wallet=None, plugin=None):
|
||||||
|
"""Show the details of a single will-executor.
|
||||||
|
|
||||||
|
arg:str:url:The will-executor URL.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_show", url)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_add(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
address="",
|
||||||
|
base_fee=0,
|
||||||
|
info=None,
|
||||||
|
wallet=None,
|
||||||
|
plugin=None,
|
||||||
|
):
|
||||||
|
"""Add a new will-executor (not selected by default).
|
||||||
|
|
||||||
|
arg:str:url:The will-executor base URL.
|
||||||
|
arg:str:address:The executor fee address for this network.
|
||||||
|
arg:int:base_fee:The executor base fee in satoshis.
|
||||||
|
arg:str:info:A human-readable description.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_add", url, address, base_fee, info)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_update(
|
||||||
|
self,
|
||||||
|
url,
|
||||||
|
address=None,
|
||||||
|
base_fee=None,
|
||||||
|
info=None,
|
||||||
|
promo_code=None,
|
||||||
|
rename_to=None,
|
||||||
|
wallet=None,
|
||||||
|
plugin=None,
|
||||||
|
):
|
||||||
|
"""Update an existing will-executor (only the given fields).
|
||||||
|
|
||||||
|
arg:str:url:The will-executor URL to update.
|
||||||
|
arg:str:address:The new fee address.
|
||||||
|
arg:int:base_fee:The new base fee in satoshis.
|
||||||
|
arg:str:info:The new description.
|
||||||
|
arg:str:promo_code:The new promo code.
|
||||||
|
arg:str:rename_to:Optionally move the record to a new URL.
|
||||||
|
"""
|
||||||
|
return _call(
|
||||||
|
plugin,
|
||||||
|
wallet,
|
||||||
|
"willexecutors_update",
|
||||||
|
url,
|
||||||
|
address,
|
||||||
|
base_fee,
|
||||||
|
info,
|
||||||
|
promo_code,
|
||||||
|
rename_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_select(
|
||||||
|
self, url, value=True, wallet=None, plugin=None
|
||||||
|
):
|
||||||
|
"""Select (or deselect) a will-executor.
|
||||||
|
|
||||||
|
arg:str:url:The will-executor URL.
|
||||||
|
arg:bool:value:True to select, False to deselect.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_select", [url], value)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_delete(self, urls, wallet=None, plugin=None):
|
||||||
|
"""Delete one or more will-executors.
|
||||||
|
|
||||||
|
arg:json:urls:A JSON array of executor URLs (e.g. ``["https://we.example.com"]``).
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_delete", urls)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_ping(self, urls=None, wallet=None, plugin=None):
|
||||||
|
"""Ping the selected (or the given) will-executor servers.
|
||||||
|
|
||||||
|
Updates status/base_fee/address from each server and saves. Returns
|
||||||
|
``{url: {status, ok}}``.
|
||||||
|
|
||||||
|
arg:json:urls:Optional JSON array of URLs to ping; defaults to the selected executors.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_ping", urls)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_download(self, wallet=None, plugin=None):
|
||||||
|
"""Download the will-executor list from the welist server and merge it.
|
||||||
|
|
||||||
|
Returns the number of records downloaded and the new total.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_download")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_import(self, path, wallet=None, plugin=None):
|
||||||
|
"""Import will-executors from a JSON file (``{url: record}``).
|
||||||
|
|
||||||
|
arg:str:path:Path to the JSON file.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_import", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def willexecutors_export(self, path, wallet=None, plugin=None):
|
||||||
|
"""Export the will-executors to a JSON file.
|
||||||
|
|
||||||
|
arg:str:path:Destination file path.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "willexecutors_export", path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Will
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_status(self, wallet=None, plugin=None):
|
||||||
|
"""Show the current will: per-transaction status, locktime and executors.
|
||||||
|
|
||||||
|
Returns a JSON object with a per-txid detail list and global status counts.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "will_status")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_check(self, wallet=None, plugin=None):
|
||||||
|
"""Check the local coherence of the will (heirs, executors, fees, locktime).
|
||||||
|
|
||||||
|
Returns ``{"valid": true}`` when coherent, or raises a descriptive error.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "will_check")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_prepare(self, wallet=None, plugin=None):
|
||||||
|
"""Run the full prepare/inheritance flow (check, rebuild, persist).
|
||||||
|
|
||||||
|
Returns a JSON object with ``result`` (``coherent``, ``rebuilt``,
|
||||||
|
``expired``, ``postponed``) and, when needed, the invalidation
|
||||||
|
transaction to sign and broadcast.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "prepare_will")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_autorebuild(self, wallet=None, plugin=None):
|
||||||
|
"""Run the automatic rebuild flow in one shot (check, rebuild, sign, push).
|
||||||
|
|
||||||
|
The same flow the GUI runs automatically on new wallet transactions:
|
||||||
|
the delivery date is anticipated by one day to orphan the old will on-chain
|
||||||
|
and, only when the anticipated locktime crosses the Check Alive threshold
|
||||||
|
(or the threshold is already in the past), an invalidation transaction is
|
||||||
|
returned instead. Signing needs a passwordless wallet.
|
||||||
|
|
||||||
|
Returns a JSON object with ``result``: ``valid``, ``no_heirs``,
|
||||||
|
``invalidated`` (with ``invalidation_tx``), ``nothing``,
|
||||||
|
``needs_signing`` or ``rebuilt``.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "auto_rebuild")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nwp", plugin_name)
|
||||||
|
async def will_sign(self, txid=None, password=None, wallet=None, plugin=None):
|
||||||
|
"""Sign the valid, not-yet-complete will transactions (or just one).
|
||||||
|
|
||||||
|
Updates the COMPLETE status and the signature counters and persists.
|
||||||
|
|
||||||
|
arg:str:txid:Optional transaction id to sign; signs all valid ones when omitted.
|
||||||
|
"""
|
||||||
|
txids = [txid] if txid is not None else None
|
||||||
|
txs = _call(plugin, wallet, "sign_transactions", password, txids)
|
||||||
|
return {wid: str(tx) for wid, tx in txs.items()}
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_broadcast(
|
||||||
|
self, txid=None, force=False, wallet=None, plugin=None
|
||||||
|
):
|
||||||
|
"""Send the signed will transactions to their will-executors (in parallel).
|
||||||
|
|
||||||
|
Updates the PUSHED/PUSH_FAIL statuses and persists. Returns ``{url: status}``.
|
||||||
|
|
||||||
|
arg:str:txid:Optional transaction id to broadcast; all valid+signed ones when omitted.
|
||||||
|
arg:bool:force:Force re-pushing transactions already marked as PUSHED.
|
||||||
|
"""
|
||||||
|
txids = [txid] if txid is not None else None
|
||||||
|
return _call(plugin, wallet, "push_transactions_to_willexecutors", force, txids)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_export(self, path, wallet=None, plugin=None):
|
||||||
|
"""Export the whole will to a JSON file.
|
||||||
|
|
||||||
|
arg:str:path:Destination file path.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "export_will", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_import_merge(self, path, wallet=None, plugin=None):
|
||||||
|
"""Merge a will file into the current will (PSBTs and statuses are merged).
|
||||||
|
|
||||||
|
arg:str:path:Path to the will JSON file.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "merge_will_from_file", path)
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_invalidate(self, wallet=None, plugin=None):
|
||||||
|
"""Build the on-chain invalidation transaction for the current will.
|
||||||
|
|
||||||
|
Returns ``{txid, tx}`` (or nulls when there is nothing to invalidate); the
|
||||||
|
transaction still needs to be signed and broadcast.
|
||||||
|
"""
|
||||||
|
return _call(plugin, wallet, "invalidate_will_command")
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_command("nw", plugin_name)
|
||||||
|
async def will_check_executor(self, txid=None, wallet=None, plugin=None):
|
||||||
|
"""Ask the will-executors whether they hold our pushed transactions.
|
||||||
|
|
||||||
|
Runs the searchtx check in parallel, applies the per-item status and
|
||||||
|
persists. Returns ``{txid: {url, pushed, checked, check_fail}}``.
|
||||||
|
|
||||||
|
arg:str:txid:Optional transaction id to check; checks all pending ones when omitted.
|
||||||
|
"""
|
||||||
|
txids = [txid] if txid is not None else None
|
||||||
|
return _call(plugin, wallet, "check_transactions", txids)
|
||||||
1297
bal/cli/controller.py
Normal file
1297
bal/cli/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
21
bal/cli/plugin.py
Normal file
21
bal/cli/plugin.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""
|
||||||
|
bal.cli.plugin
|
||||||
|
==============
|
||||||
|
|
||||||
|
The headless (command-line) entry point of the plugin.
|
||||||
|
|
||||||
|
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` without
|
||||||
|
adding any Qt hooks or per-window state. Electrum instantiates this class when
|
||||||
|
the plugin runs with ``gui_name='cmdline'`` (the daemon loads
|
||||||
|
``bal/cmdline.py``, which re-exports it), and it is the object injected as
|
||||||
|
``plugin`` into every ``bal_*`` command by ``electrum.commands.plugin_command``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ..core.plugin_base import BalPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BalPlugin):
|
||||||
|
"""Minimal ``BasePlugin`` subclass for the command-line front-end."""
|
||||||
|
|
||||||
|
def __init__(self, parent, config, name):
|
||||||
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
69
bal/cmdline.py
Normal file
69
bal/cmdline.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
bal.cmdline
|
||||||
|
===========
|
||||||
|
|
||||||
|
Compatibility shim for Electrum's plugin loader (command-line front-end).
|
||||||
|
|
||||||
|
Electrum loads a plugin with ``gui_name='cmdline'`` by importing the
|
||||||
|
``cmdline`` module of the plugin package and looking for a ``Plugin`` class.
|
||||||
|
The real implementation lives in the ``bal.cli`` sub-package, so this module
|
||||||
|
re-exports ``Plugin`` from ``bal.cli.plugin``.
|
||||||
|
|
||||||
|
Like ``qt.py``, this file is not a one-line relative import because the very
|
||||||
|
same code may be loaded as an *external* plugin from a ``.zip``, where Electrum
|
||||||
|
imports the package under the synthetic top-level name
|
||||||
|
``electrum_external_plugins.bal`` and never registers the intermediate parent
|
||||||
|
packages. See the module docstring of ``bal.qt`` for the full rationale. The
|
||||||
|
shim resolves the run-time package name, backfills the missing parents into
|
||||||
|
``sys.modules`` and imports the real implementation via
|
||||||
|
:func:`importlib.import_module`.
|
||||||
|
|
||||||
|
Unlike ``qt.py``, this module MUST never import PyQt (the daemon loads it in a
|
||||||
|
headless process).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_packages(pkg_name: str) -> None:
|
||||||
|
"""Make sure every ancestor package of *pkg_name* is in ``sys.modules``.
|
||||||
|
|
||||||
|
When loaded from a zip as an external plugin, Electrum only executes the
|
||||||
|
plugin package ``__init__`` and the ``cmdline`` module. The synthetic root
|
||||||
|
package (e.g. ``electrum_external_plugins``) and any intermediate packages
|
||||||
|
may be missing from ``sys.modules``, which breaks relative/absolute
|
||||||
|
sub-module imports. We backfill them here using this module's own loader
|
||||||
|
so that ``importlib`` can find sibling sub-packages.
|
||||||
|
"""
|
||||||
|
parts = pkg_name.split(".")
|
||||||
|
# Walk from the top-most ancestor down to (but not including) pkg_name.
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
ancestor = ".".join(parts[:i])
|
||||||
|
if ancestor in sys.modules:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
importlib.import_module(ancestor)
|
||||||
|
except Exception:
|
||||||
|
# The synthetic root (e.g. 'electrum_external_plugins') often has no
|
||||||
|
# real spec. Create a minimal namespace package stub so that the
|
||||||
|
# import machinery can still resolve its children.
|
||||||
|
import types
|
||||||
|
|
||||||
|
module = types.ModuleType(ancestor)
|
||||||
|
module.__path__ = [] # mark as a (namespace) package
|
||||||
|
sys.modules[ancestor] = module
|
||||||
|
|
||||||
|
|
||||||
|
# The package this module belongs to. Could be 'electrum.plugins.bal' (internal)
|
||||||
|
# or 'electrum_external_plugins.bal' (external zip), depending on how Electrum
|
||||||
|
# loaded us.
|
||||||
|
_PKG = __package__ or "bal"
|
||||||
|
|
||||||
|
_ensure_parent_packages(_PKG)
|
||||||
|
|
||||||
|
# Import the real implementation using the fully-qualified, run-time package
|
||||||
|
# name so it works regardless of the synthetic prefix Electrum assigned.
|
||||||
|
_plugin_module = importlib.import_module(_PKG + ".cli.plugin")
|
||||||
|
|
||||||
|
Plugin = _plugin_module.Plugin # noqa: F401 (re-exported for Electrum)
|
||||||
296
bal/core/animated_qr.py
Normal file
296
bal/core/animated_qr.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""
|
||||||
|
bal.core.animated_qr
|
||||||
|
====================
|
||||||
|
|
||||||
|
GUI-free implementation of the BBQR animated-QR transfer format used to
|
||||||
|
move BAL will data between devices.
|
||||||
|
|
||||||
|
Supported wire format (self-describing and order-independent on receive):
|
||||||
|
|
||||||
|
* **BBQR** (Coinkite)::
|
||||||
|
B$<encoding><type><2 base36 total><2 base36 index><payload>
|
||||||
|
Equal-length text frames; the payload is uppercase hex, RFC-4648
|
||||||
|
base32, or raw-deflate (``wbits=-10``) zlib plus base32.
|
||||||
|
|
||||||
|
Everything is implemented from scratch on top of the Python standard library
|
||||||
|
only (``zlib``, ``base64``), so the shipped plugin zip stays a
|
||||||
|
self-contained bundle with no third-party dependencies (house rule).
|
||||||
|
|
||||||
|
This module never imports Qt or any Electrum GUI code (house rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import zlib
|
||||||
|
from typing import Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Errors & safety caps
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
class AnimatedQrError(ValueError):
|
||||||
|
"""Base error for all animated-QR codec failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class FormatNotDetectedError(AnimatedQrError):
|
||||||
|
"""The scanned text does not look like any known animated-QR format."""
|
||||||
|
|
||||||
|
|
||||||
|
class TransferConflictError(AnimatedQrError):
|
||||||
|
"""An incoming frame belongs to a different transfer than the open one."""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionLimitError(AnimatedQrError):
|
||||||
|
"""A receive session exceeded its safety caps."""
|
||||||
|
|
||||||
|
|
||||||
|
# Safety caps for untrusted scanner input.
|
||||||
|
_MAX_SESSION_PARTS = 20000
|
||||||
|
_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# BBQR (Coinkite)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_BBQR_PREFIX = "B$"
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_base36(n: int) -> str:
|
||||||
|
if not 0 <= n <= 1295:
|
||||||
|
raise AnimatedQrError("BBQR part count out of range")
|
||||||
|
|
||||||
|
def digit(x: int) -> str:
|
||||||
|
return chr(48 + x) if x < 10 else chr(65 + x - 10)
|
||||||
|
|
||||||
|
return digit(n // 36) + digit(n % 36)
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_base32(data: bytes) -> str:
|
||||||
|
return base64.b32encode(data).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_encode(raw: bytes, encoding: str) -> Tuple[str, str, int]:
|
||||||
|
"""Return ``(encoding, encoded_text, split_mod)`` honouring the reference."""
|
||||||
|
if encoding == "H":
|
||||||
|
return "H", raw.hex().upper(), 2
|
||||||
|
if encoding == "Z":
|
||||||
|
compressor = zlib.compressobj(wbits=-10)
|
||||||
|
compressed = compressor.compress(raw) + compressor.flush()
|
||||||
|
if len(compressed) < len(raw):
|
||||||
|
return "Z", _bbqr_base32(compressed), 8
|
||||||
|
encoding = "2"
|
||||||
|
if encoding != "2":
|
||||||
|
raise AnimatedQrError("unknown BBQR encoding")
|
||||||
|
return "2", _bbqr_base32(raw), 8
|
||||||
|
|
||||||
|
|
||||||
|
def bbqr_frames(payload: bytes, budget_chars: int, encoding: str = "Z", type_code: str = "B") -> List[str]:
|
||||||
|
"""Encode ``payload`` into BBQR frames (``B$<enc><type><N><n>…``)."""
|
||||||
|
if len(type_code) != 1 or not type_code.isalnum():
|
||||||
|
raise AnimatedQrError("bad BBQR type code")
|
||||||
|
encoding, encoded, split_mod = _bbqr_encode(payload, encoding)
|
||||||
|
chunk = budget_chars - 8
|
||||||
|
if chunk < split_mod:
|
||||||
|
raise AnimatedQrError("QR budget too small for a BBQR frame")
|
||||||
|
chunk -= chunk % split_mod
|
||||||
|
if chunk < 1:
|
||||||
|
raise AnimatedQrError("QR budget too small for a BBQR frame")
|
||||||
|
if len(payload) > _MAX_MESSAGE_BYTES:
|
||||||
|
raise AnimatedQrError("BBQR payload exceeds the size cap")
|
||||||
|
total = -(-len(encoded) // chunk)
|
||||||
|
if total > 1295:
|
||||||
|
raise AnimatedQrError("BBQR transfer demands too many parts")
|
||||||
|
header = _BBQR_PREFIX + encoding + type_code + _bbqr_base36(total)
|
||||||
|
frames: List[str] = []
|
||||||
|
pos = 0
|
||||||
|
for index in range(total):
|
||||||
|
frames.append(header + _bbqr_base36(index) + encoded[pos : pos + chunk])
|
||||||
|
pos += chunk
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def bbqr_parse_part(frame_text: str) -> Tuple[str, str, int, int, str]:
|
||||||
|
"""Parse a BBQR frame into ``(encoding, type_code, total, index, payload)``."""
|
||||||
|
frame_text = frame_text.strip()
|
||||||
|
if len(frame_text) < 10 or not frame_text.startswith(_BBQR_PREFIX):
|
||||||
|
raise AnimatedQrError("not a BBQR frame")
|
||||||
|
encoding = frame_text[2]
|
||||||
|
type_code = frame_text[3]
|
||||||
|
if encoding not in ("H", "2", "Z"):
|
||||||
|
raise AnimatedQrError("unknown BBQR encoding")
|
||||||
|
try:
|
||||||
|
total = int(frame_text[4:6], 36)
|
||||||
|
index = int(frame_text[6:8], 36)
|
||||||
|
except ValueError:
|
||||||
|
raise AnimatedQrError("bad BBQR part numbers") from None
|
||||||
|
if total < 1 or not 0 <= index < total:
|
||||||
|
raise AnimatedQrError("bad BBQR part numbers")
|
||||||
|
if index >= _MAX_SESSION_PARTS:
|
||||||
|
raise AnimatedQrError("BBQR part number out of range")
|
||||||
|
return encoding, type_code, total, index, frame_text[8:]
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_decode(encoded_parts: Sequence[str], encoding: str) -> bytes:
|
||||||
|
pieces: List[bytes] = []
|
||||||
|
for part in encoded_parts:
|
||||||
|
if encoding == "H":
|
||||||
|
try:
|
||||||
|
pieces.append(bytes.fromhex(part))
|
||||||
|
except ValueError:
|
||||||
|
raise AnimatedQrError("invalid BBQR hex payload") from None
|
||||||
|
continue
|
||||||
|
padding = (8 - (len(part) % 8)) % 8
|
||||||
|
try:
|
||||||
|
pieces.append(base64.b32decode(part + "=" * padding))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise AnimatedQrError("invalid BBQR base32 payload") from None
|
||||||
|
raw = b"".join(pieces)
|
||||||
|
if encoding == "Z":
|
||||||
|
try:
|
||||||
|
inflater = zlib.decompressobj(wbits=-10)
|
||||||
|
out = inflater.decompress(raw, _MAX_MESSAGE_BYTES + 1)
|
||||||
|
except zlib.error:
|
||||||
|
raise AnimatedQrError("invalid BBQR zlib payload") from None
|
||||||
|
if len(out) > _MAX_MESSAGE_BYTES or inflater.unconsumed_tail:
|
||||||
|
raise AnimatedQrError("BBQR payload exceeds the size cap")
|
||||||
|
return out
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Format detection & per-frame identity for the shared debounce
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
FORMAT_LABELS = {
|
||||||
|
"bbqr": "BBQR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_name(fmt: str) -> str:
|
||||||
|
"""Human-readable name of a wire format for UI labels."""
|
||||||
|
return FORMAT_LABELS.get(fmt, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_format(text: str) -> Optional[str]:
|
||||||
|
"""Return the wire format of a scanned string, or ``None``."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.startswith(_BBQR_PREFIX):
|
||||||
|
return "bbqr"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_for_detection(text: str) -> Tuple[str, str, int, int]:
|
||||||
|
"""Parse a frame and return ``(format, session_key, frame_total, index)``.
|
||||||
|
|
||||||
|
``session_key`` identifies the transfer the frame belongs to and drives
|
||||||
|
the shared reset/ignore/accept debounce. Raises
|
||||||
|
:class:`AnimatedQrError` when the text cannot be parsed.
|
||||||
|
"""
|
||||||
|
fmt = detect_format(text)
|
||||||
|
if fmt == "bbqr":
|
||||||
|
encoding, type_code, total, index, _payload = bbqr_parse_part(text)
|
||||||
|
return "bbqr", "bbqr:{}{}:{}".format(encoding, type_code, total), total, index
|
||||||
|
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Receive session (order-independent assembly)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class _BbqrSession:
|
||||||
|
def __init__(self):
|
||||||
|
self._total = 0
|
||||||
|
self._encoding = ""
|
||||||
|
self._type_code = ""
|
||||||
|
self._parts: Dict[int, str] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self._total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def received(self) -> int:
|
||||||
|
return len(self._parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def done(self) -> bool:
|
||||||
|
return bool(self._total) and len(self._parts) >= self._total
|
||||||
|
|
||||||
|
def add(self, text: str) -> str:
|
||||||
|
encoding, type_code, total, index, payload = bbqr_parse_part(text)
|
||||||
|
if self._total:
|
||||||
|
if (encoding, type_code, total) != (self._encoding, self._type_code, self._total):
|
||||||
|
raise TransferConflictError("BBQR frame header changed")
|
||||||
|
else:
|
||||||
|
self._total = total
|
||||||
|
self._encoding = encoding
|
||||||
|
self._type_code = type_code
|
||||||
|
if index in self._parts:
|
||||||
|
return "dup"
|
||||||
|
self._parts[index] = payload
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def resolve(self) -> Tuple[str, bool]:
|
||||||
|
ordered = [self._parts[i] for i in range(self._total)]
|
||||||
|
raw = _bbqr_decode(ordered, self._encoding)
|
||||||
|
return _transfer_text(raw), False
|
||||||
|
|
||||||
|
|
||||||
|
def _transfer_text(raw: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise AnimatedQrError("decoded transfer is not valid UTF-8") from None
|
||||||
|
|
||||||
|
|
||||||
|
class AnimatedQrSession:
|
||||||
|
"""Facade over the per-format receive sessions used by the QR import page."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._inner: Optional[object] = None
|
||||||
|
self._fmt: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format(self) -> Optional[str]:
|
||||||
|
return self._fmt
|
||||||
|
|
||||||
|
def add_part(self, text: str) -> str:
|
||||||
|
"""Feed one scanned frame; returns ``"ok"``/``"dup"``, raises on bad input."""
|
||||||
|
fmt = detect_format(text)
|
||||||
|
if fmt is None:
|
||||||
|
raise FormatNotDetectedError("Not a supported QR transfer format")
|
||||||
|
if self._inner is None:
|
||||||
|
self._fmt = fmt
|
||||||
|
self._inner = self._make(fmt)
|
||||||
|
elif fmt != self._fmt:
|
||||||
|
raise TransferConflictError(
|
||||||
|
"Switched QR format mid-import ({} -> {})".format(self.format, fmt)
|
||||||
|
)
|
||||||
|
return self._inner.add(text) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make(fmt: str) -> object:
|
||||||
|
if fmt == "bbqr":
|
||||||
|
return _BbqrSession()
|
||||||
|
raise AssertionError("unknown animated-QR format {}".format(fmt))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self._inner.total if self._inner is not None else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def received(self) -> int:
|
||||||
|
return self._inner.received if self._inner is not None else 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def done(self) -> bool:
|
||||||
|
return bool(self._inner is not None and self._inner.done)
|
||||||
|
|
||||||
|
def resolve(self) -> Tuple[str, bool]:
|
||||||
|
if self._inner is None:
|
||||||
|
raise AnimatedQrError("no transfer has been received")
|
||||||
|
return self._inner.resolve() # type: ignore[no-any-return]
|
||||||
291
bal/core/checkalive.py
Normal file
291
bal/core/checkalive.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
|
||||||
|
A RELATIVE Check Alive value is anchored according to its SIGN:
|
||||||
|
|
||||||
|
* NEGATIVE (``"-30d"`` / ``"-1y"``): counted BACKWARDS from the delivery date,
|
||||||
|
i.e. ``delivery + N days`` (``-30d`` = 30 days BEFORE the delivery). This is
|
||||||
|
the historical meaning of a relative threshold.
|
||||||
|
* POSITIVE (``"30d"`` / ``"1y"``): counted FORWARD from the inheritance
|
||||||
|
creation date (``min(WillItem.time)`` of the valid will items), i.e.
|
||||||
|
``creation + N days``. The creation date is frozen in the wallet DB, so the
|
||||||
|
reference never drifts with the clock.
|
||||||
|
* When the anchor is unknown (no built will yet, or imported items without a
|
||||||
|
``time``). The reference falls back to *now*.
|
||||||
|
|
||||||
|
Pure, GUI-free. The GUI raises :class:`CheckAliveError` /
|
||||||
|
:class:`CheckAliveAfterDeliveryError` to trigger the postpone/invalidate flow;
|
||||||
|
the decision that they *should* be raised lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
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, reason: str | None = None):
|
||||||
|
self.timestamp_to_check = timestamp_to_check
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
message = "Check alive expired please update it: {}".format(
|
||||||
|
datetime.fromtimestamp(self.timestamp_to_check, tz=timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
if self.reason:
|
||||||
|
message += f" ({self.reason})"
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
class CheckAliveAfterDeliveryError(Exception):
|
||||||
|
"""Raised when the "check alive" date is not before the delivery date.
|
||||||
|
|
||||||
|
Deliberately NOT a subclass of :class:`CheckAliveError`: the existing
|
||||||
|
``except CheckAliveError`` handlers invalidate the inheritance on-chain,
|
||||||
|
while a Check Alive that lands on/after the delivery is a configuration
|
||||||
|
error that must only abort the current action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timestamp_to_check, delivery_timestamp=None):
|
||||||
|
self.timestamp_to_check = timestamp_to_check
|
||||||
|
self.delivery_timestamp = delivery_timestamp
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
check_date = datetime.fromtimestamp(
|
||||||
|
self.timestamp_to_check, tz=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
if self.delivery_timestamp is None:
|
||||||
|
return (
|
||||||
|
"Check alive date must be after today and before the delivery "
|
||||||
|
f"date: {check_date}"
|
||||||
|
)
|
||||||
|
delivery_date = datetime.fromtimestamp(
|
||||||
|
self.delivery_timestamp, tz=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
return (
|
||||||
|
"Check alive date must be after today and before the delivery "
|
||||||
|
f"date: {check_date} >= {delivery_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_delivery_date(
|
||||||
|
will_settings: Any,
|
||||||
|
now: float | None = None,
|
||||||
|
built_locktime: float | int | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""Return the delivery date every Check Alive calculation is anchored to.
|
||||||
|
|
||||||
|
This is the single source of the delivery reference, shared by
|
||||||
|
:func:`resolve_date_to_check` and :func:`resolve_guard_threshold`:
|
||||||
|
|
||||||
|
* an ABSOLUTE stored locktime is used as-is;
|
||||||
|
* a RELATIVE stored locktime (``"2y"``) is resolved forward from *now*,
|
||||||
|
UNLESS a will has already been built: its frozen delivery date
|
||||||
|
(``built_locktime``, the locktime inside the signed tx) is then
|
||||||
|
authoritative, so an unchanged will never drifts as the clock advances.
|
||||||
|
|
||||||
|
Returns ``None`` when no locktime is stored.
|
||||||
|
"""
|
||||||
|
locktime_raw = will_settings.get("locktime")
|
||||||
|
if locktime_raw is None:
|
||||||
|
return None
|
||||||
|
if BalTimestamp(locktime_raw).unit is not None and built_locktime:
|
||||||
|
return BalTimestamp(int(built_locktime)).to_timestamp(now)
|
||||||
|
return BalTimestamp(locktime_raw).to_timestamp(now)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_relative_check_alive_date(
|
||||||
|
threshold_raw: Any,
|
||||||
|
delivery_date: float | None,
|
||||||
|
creation_date: float | None = None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Resolve a RELATIVE Check Alive value to an absolute timestamp.
|
||||||
|
|
||||||
|
The SIGN of the value selects the anchor (the single calculation rule):
|
||||||
|
|
||||||
|
* negative (``"-30d"``): ``delivery_date + N days`` (N < 0 -> BEFORE the
|
||||||
|
delivery);
|
||||||
|
* positive (``"30d"``): ``creation_date + N days`` (the inheritance
|
||||||
|
creation date);
|
||||||
|
* when the selected anchor is unknown, *now* is used as a fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
threshold_raw: the stored ``"<n>d"`` / ``"<n>y"`` value (may be
|
||||||
|
negative).
|
||||||
|
delivery_date: the resolved delivery timestamp (see
|
||||||
|
:func:`resolve_delivery_date`), or ``None``.
|
||||||
|
creation_date: the inheritance creation timestamp
|
||||||
|
(``Will.get_creation_time``), or ``None``.
|
||||||
|
now: overridable clock for tests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The absolute reference timestamp (float, UNIX seconds).
|
||||||
|
"""
|
||||||
|
threshold = BalTimestamp(threshold_raw)
|
||||||
|
if threshold.unit is None:
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
if threshold.value < 0:
|
||||||
|
anchor = delivery_date
|
||||||
|
else:
|
||||||
|
anchor = creation_date
|
||||||
|
if anchor is None:
|
||||||
|
anchor = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||||
|
return threshold.to_date(anchor).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_date_to_check(
|
||||||
|
is_basic_mode: bool,
|
||||||
|
will_settings: Any,
|
||||||
|
now: float | None = None,
|
||||||
|
built_locktime: float | int | None = None,
|
||||||
|
creation_date: float | 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*.
|
||||||
|
* ADVANCED mode: an ABSOLUTE stored threshold is returned unchanged; a
|
||||||
|
RELATIVE one is resolved by :func:`resolve_relative_check_alive_date`
|
||||||
|
(negative -> anchored to the delivery, positive -> anchored to the
|
||||||
|
inheritance creation date).
|
||||||
|
|
||||||
|
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).
|
||||||
|
creation_date: the inheritance creation timestamp
|
||||||
|
(``Will.get_creation_time``); ``None`` falls back to *now*.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The reference timestamp (float, UNIX seconds).
|
||||||
|
"""
|
||||||
|
if is_basic_mode:
|
||||||
|
return (now if now is not None else datetime.now(tz=timezone.utc).timestamp())
|
||||||
|
|
||||||
|
threshold = BalTimestamp(will_settings["threshold"])
|
||||||
|
if threshold.unit is None:
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
delivery_date = resolve_delivery_date(
|
||||||
|
will_settings, now=now, built_locktime=built_locktime
|
||||||
|
)
|
||||||
|
return resolve_relative_check_alive_date(
|
||||||
|
will_settings["threshold"],
|
||||||
|
delivery_date,
|
||||||
|
creation_date=creation_date,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_guard_threshold(
|
||||||
|
is_basic_mode: bool,
|
||||||
|
will_settings: Any,
|
||||||
|
now: float | None = None,
|
||||||
|
creation_date: float | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""Resolve the "locktime is lower than threshold" guard's reference.
|
||||||
|
|
||||||
|
The guard compares the stored settings on ONE reference frame: the
|
||||||
|
delivery (``locktime``, kept as at the call site) against this threshold.
|
||||||
|
|
||||||
|
Unlike :func:`resolve_date_to_check` -- which may be *anchored* to the
|
||||||
|
built will's frozen tx locktime so that an unchanged will never reads as
|
||||||
|
expired -- this helper resolves the threshold from the **stored settings
|
||||||
|
alone** (no ``built_locktime``). Otherwise, when the stored relative
|
||||||
|
locktime is shorter than the frozen locktime of an old (still valid) built
|
||||||
|
will (e.g. the delivery was shortened from ``"2y"`` to ``"1y"``), the guard
|
||||||
|
would compare the fresh "1y" locktime against the old will's anchored
|
||||||
|
threshold and wrongly fire, even though locktime > threshold by the
|
||||||
|
settings themselves.
|
||||||
|
|
||||||
|
* BASIC mode: no threshold exists. Returns ``None`` and the caller falls
|
||||||
|
back to comparing the locktime against ``date_to_check`` (= now), so its
|
||||||
|
behaviour is unchanged.
|
||||||
|
* ADVANCED mode with an ABSOLUTE threshold: returns the stored threshold
|
||||||
|
as-is.
|
||||||
|
* ADVANCED mode with a RELATIVE threshold: resolved by
|
||||||
|
:func:`resolve_relative_check_alive_date` (negative -> delivery anchor,
|
||||||
|
positive -> inheritance creation date), using *now* for the delivery
|
||||||
|
reading and never a built tx.
|
||||||
|
|
||||||
|
Returns ``None`` when there is no threshold to enforce (BASIC mode or a
|
||||||
|
missing stored value).
|
||||||
|
"""
|
||||||
|
if is_basic_mode:
|
||||||
|
return None
|
||||||
|
threshold_raw = will_settings.get("threshold")
|
||||||
|
if threshold_raw is None:
|
||||||
|
return None
|
||||||
|
threshold = BalTimestamp(threshold_raw)
|
||||||
|
if threshold.unit is None:
|
||||||
|
return threshold.to_timestamp()
|
||||||
|
delivery_date = resolve_delivery_date(will_settings, now=now, built_locktime=None)
|
||||||
|
return resolve_relative_check_alive_date(
|
||||||
|
threshold_raw,
|
||||||
|
delivery_date,
|
||||||
|
creation_date=creation_date,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_check_alive_date_valid(
|
||||||
|
date_to_check: float | None,
|
||||||
|
delivery_date: float | None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when the Check Alive date is inside ``(now, delivery_date)``.
|
||||||
|
|
||||||
|
The single validity rule for the Check Alive reference: it must be AFTER
|
||||||
|
today and BEFORE the delivery. A ``None`` ``delivery_date`` skips the
|
||||||
|
upper bound (no delivery is known yet).
|
||||||
|
"""
|
||||||
|
if date_to_check is None:
|
||||||
|
return False
|
||||||
|
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||||
|
if date_to_check < current:
|
||||||
|
return False
|
||||||
|
if delivery_date is not None and date_to_check >= delivery_date:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_check_alive_valid(
|
||||||
|
is_basic_mode: bool,
|
||||||
|
date_to_check: float | None,
|
||||||
|
delivery_date: float | None = None,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Raise when the Check Alive date is not valid.
|
||||||
|
|
||||||
|
Only ADVANCED mode can be invalid: 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.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CheckAliveError: the date is in the past.
|
||||||
|
CheckAliveAfterDeliveryError: the date is on/after the delivery.
|
||||||
|
"""
|
||||||
|
if is_basic_mode:
|
||||||
|
return
|
||||||
|
if date_to_check is None:
|
||||||
|
return
|
||||||
|
current = now if now is not None else datetime.now(tz=timezone.utc).timestamp()
|
||||||
|
if date_to_check < current:
|
||||||
|
raise CheckAliveError(date_to_check)
|
||||||
|
if delivery_date is not None and date_to_check >= delivery_date:
|
||||||
|
raise CheckAliveAfterDeliveryError(date_to_check, delivery_date)
|
||||||
@@ -21,6 +21,8 @@ Will-executor "heirs" are synthetic entries whose key starts with the
|
|||||||
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
``w!ll3x3c"`` marker; they are skipped by most heir comparisons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
import math
|
import math
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
@@ -31,6 +33,7 @@ from typing import (
|
|||||||
Dict,
|
Dict,
|
||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
import dns
|
import dns
|
||||||
@@ -56,7 +59,7 @@ from electrum.util import (
|
|||||||
write_json_file,
|
write_json_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .util import Util
|
from .util import Util, copy_structure
|
||||||
from .willexecutors import Willexecutors
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -65,6 +68,19 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_txt_records(url: str) -> Tuple[Any, bool]:
|
||||||
|
"""Resolve TXT records with DNSSEC validation.
|
||||||
|
|
||||||
|
``electrum.dnssec.query`` became an ``async`` function in Electrum 4.7.2,
|
||||||
|
so adapt the call for this synchronous context while staying compatible
|
||||||
|
with older synchronous implementations.
|
||||||
|
"""
|
||||||
|
query = dnssec.query
|
||||||
|
if inspect.iscoroutinefunction(query):
|
||||||
|
return asyncio.run(query(url, dns.rdatatype.TXT))
|
||||||
|
return cast(Tuple[Any, bool], query(url, dns.rdatatype.TXT))
|
||||||
|
|
||||||
# Column layout of a stored heir list. These indices are part of the on-disk
|
# Column layout of a stored heir list. These indices are part of the on-disk
|
||||||
# wallet format and are relied upon all over the codebase, so they must NEVER
|
# wallet format and are relied upon all over the codebase, so they must NEVER
|
||||||
# be reordered.
|
# be reordered.
|
||||||
@@ -75,6 +91,8 @@ HEIR_REAL_AMOUNT = 3 # resolved amount once percentages are computed
|
|||||||
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
HEIR_DUST_AMOUNT = 4 # amount when below dust threshold (marked "DUST: ...")
|
||||||
TRANSACTION_LABEL = "inheritance transaction"
|
TRANSACTION_LABEL = "inheritance transaction"
|
||||||
|
|
||||||
|
OP_RETURN_PREFIX = "OP_RETURN:"
|
||||||
|
|
||||||
|
|
||||||
class AliasNotFoundException(Exception):
|
class AliasNotFoundException(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -86,6 +104,27 @@ def reduce_outputs(in_amount, out_amount, fee, outputs):
|
|||||||
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
output.value = math.floor((in_amount - fee) / out_amount * output.value)
|
||||||
|
|
||||||
|
|
||||||
|
def is_op_return_address(address: str) -> bool:
|
||||||
|
return str(address).startswith(OP_RETURN_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def get_op_return_hex(address: str) -> Optional[str]:
|
||||||
|
if is_op_return_address(address):
|
||||||
|
return address[len(OP_RETURN_PREFIX):]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_op_return_hex(data_hex: str) -> None:
|
||||||
|
try:
|
||||||
|
data = bytes.fromhex(data_hex)
|
||||||
|
except ValueError:
|
||||||
|
raise NotAnAddress(f"OP_RETURN data is not valid hex: {data_hex}") from None
|
||||||
|
if len(data) > 80:
|
||||||
|
raise NotAnAddress(
|
||||||
|
f"OP_RETURN data too long ({len(data)} bytes, max 80)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_op_return_script(data_hex: str) -> bytes:
|
def create_op_return_script(data_hex: str) -> bytes:
|
||||||
"""Crea scriptpubkey OP_RETURN in bytes"""
|
"""Crea scriptpubkey OP_RETURN in bytes"""
|
||||||
data = bytes.fromhex(data_hex)
|
data = bytes.fromhex(data_hex)
|
||||||
@@ -132,14 +171,22 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
real_amount = heir[HEIR_REAL_AMOUNT]
|
if is_op_return_address(heir[HEIR_ADDRESS]):
|
||||||
outputs.append(
|
data_hex = heir[HEIR_ADDRESS][len(OP_RETURN_PREFIX):]
|
||||||
PartialTxOutput.from_address_and_value(
|
op_return_script = create_op_return_script(data_hex)
|
||||||
heir[HEIR_ADDRESS], real_amount
|
outputs.append(
|
||||||
|
PartialTxOutput(value=0, scriptpubkey=op_return_script)
|
||||||
)
|
)
|
||||||
)
|
description += f"{name}\n"
|
||||||
out_amount += real_amount
|
else:
|
||||||
description += f"{name}\n"
|
real_amount = heir[HEIR_REAL_AMOUNT]
|
||||||
|
outputs.append(
|
||||||
|
PartialTxOutput.from_address_and_value(
|
||||||
|
heir[HEIR_ADDRESS], real_amount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out_amount += real_amount
|
||||||
|
description += f"{name}\n"
|
||||||
except BitcoinException as e:
|
except BitcoinException as e:
|
||||||
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
_logger.info("exception decoding output {} - {}".format(type(e), e))
|
||||||
heir[HEIR_REAL_AMOUNT] = e
|
heir[HEIR_REAL_AMOUNT] = e
|
||||||
@@ -174,7 +221,7 @@ def prepare_transactions(locktimes, available_utxos, fees, wallet):
|
|||||||
change = get_change_output(wallet, in_amount, out_amount, fee)
|
change = get_change_output(wallet, in_amount, out_amount, fee)
|
||||||
if change:
|
if change:
|
||||||
outputs.append(change)
|
outputs.append(change)
|
||||||
for i in range(0, 100):
|
for _ in range(0, 100):
|
||||||
random.shuffle(outputs)
|
random.shuffle(outputs)
|
||||||
|
|
||||||
#op_return_text = "Hello Bal!"
|
#op_return_text = "Hello Bal!"
|
||||||
@@ -230,6 +277,7 @@ def get_utxos_from_inputs(tx_inputs, tx, utxos):
|
|||||||
|
|
||||||
# TODO calculate de minimum inputs to be invalidated
|
# TODO calculate de minimum inputs to be invalidated
|
||||||
def invalidate_inheritance_transactions(wallet):
|
def invalidate_inheritance_transactions(wallet):
|
||||||
|
_logger.debug("invalidate tx in heir method")
|
||||||
# listids = []
|
# listids = []
|
||||||
utxos = {}
|
utxos = {}
|
||||||
dtxs = {}
|
dtxs = {}
|
||||||
@@ -249,7 +297,7 @@ def invalidate_inheritance_transactions(wallet):
|
|||||||
del dtxs[txid]
|
del dtxs[txid]
|
||||||
|
|
||||||
utxos = {}
|
utxos = {}
|
||||||
for txid, tx in dtxs.items():
|
for _, tx in dtxs.items():
|
||||||
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
get_utxos_from_inputs(tx.inputs(), tx, utxos)
|
||||||
|
|
||||||
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
utxos = sorted(utxos.items(), key=lambda item: len(item[1]))
|
||||||
@@ -264,39 +312,6 @@ def invalidate_inheritance_transactions(wallet):
|
|||||||
remaining[key] = value
|
remaining[key] = value
|
||||||
|
|
||||||
|
|
||||||
def print_transaction(heirs, tx, locktimes, tx_fees):
|
|
||||||
jtx = tx.to_json()
|
|
||||||
print(f"TX: {tx.txid()}\t-\tLocktime: {jtx['locktime']}")
|
|
||||||
print("---")
|
|
||||||
for inp in jtx["inputs"]:
|
|
||||||
print(f"{inp['address']}: {inp['value_sats']}")
|
|
||||||
print("---")
|
|
||||||
for out in jtx["outputs"]:
|
|
||||||
heirname = ""
|
|
||||||
for key in heirs.keys():
|
|
||||||
heir = heirs[key]
|
|
||||||
if heir[HEIR_ADDRESS] == out["address"] and str(heir[HEIR_LOCKTIME]) == str(
|
|
||||||
jtx["locktime"]
|
|
||||||
):
|
|
||||||
heirname = key
|
|
||||||
print(f"{heirname}\t{out['address']}: {out['value_sats']}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
size = tx.estimated_size()
|
|
||||||
print(
|
|
||||||
"fee: {}\texpected: {}\tsize: {}".format(
|
|
||||||
tx.input_value() - tx.output_value(), size * tx_fees, size
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
print()
|
|
||||||
try:
|
|
||||||
print(tx.serialize_to_network())
|
|
||||||
except Exception:
|
|
||||||
print("impossible to serialize")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def get_change_output(wallet, in_amount, out_amount, fee):
|
def get_change_output(wallet, in_amount, out_amount, fee):
|
||||||
change_amount = int(in_amount - out_amount - fee)
|
change_amount = int(in_amount - out_amount - fee)
|
||||||
if change_amount > wallet.dust_threshold():
|
if change_amount > wallet.dust_threshold():
|
||||||
@@ -306,40 +321,14 @@ def get_change_output(wallet, in_amount, out_amount, fee):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(value, _path="heirs", _depth=0):
|
def _json_safe(value, _path="heirs"):
|
||||||
"""Return a JSON-serializable deep copy of *value*.
|
"""Backward-compatible alias of :func:`bal.core.util.copy_structure`.
|
||||||
|
|
||||||
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
Kept so call sites that imported ``_json_safe`` directly keep working; the
|
||||||
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
actual implementation (a JSON-safe, deepcopy-free clone) lives in
|
||||||
object (e.g. one holding a ``threading.RLock``), deepcopy raises
|
``bal.core.util`` so every copy path shares one code base.
|
||||||
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
|
|
||||||
"Build will" task fails.
|
|
||||||
|
|
||||||
To make persistence robust we coerce the structure to plain
|
|
||||||
JSON-compatible types (dict / list / str / int / float / bool / None).
|
|
||||||
Anything else is converted to ``str(value)`` and logged with its path so
|
|
||||||
the offending field can be identified, instead of crashing the task.
|
|
||||||
"""
|
"""
|
||||||
# Primitive JSON scalars are kept as-is.
|
return copy_structure(value, _path=_path)
|
||||||
if value is None or isinstance(value, (bool, int, float, str)):
|
|
||||||
return value
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {
|
|
||||||
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
|
|
||||||
for k, v in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, (list, tuple)):
|
|
||||||
return [
|
|
||||||
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
|
|
||||||
for i, v in enumerate(value)
|
|
||||||
]
|
|
||||||
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
|
||||||
# was found so the real source can be fixed, then store a safe string.
|
|
||||||
_logger.error(
|
|
||||||
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
|
|
||||||
"value={!r}".format(_path, type(value).__name__, value)
|
|
||||||
)
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
class Heirs(dict, Logger):
|
class Heirs(dict, Logger):
|
||||||
@@ -348,6 +337,10 @@ class Heirs(dict, Logger):
|
|||||||
Logger.__init__(self)
|
Logger.__init__(self)
|
||||||
self.db = wallet.db
|
self.db = wallet.db
|
||||||
self.wallet = wallet
|
self.wallet = wallet
|
||||||
|
# Reason code explaining why the last buildTransactions() produced no
|
||||||
|
# transaction (None when the last build succeeded or never ran). See
|
||||||
|
# buildTransactions for the list of codes and why they exist.
|
||||||
|
self.last_build_error = None
|
||||||
d = self.db.get("heirs", {})
|
d = self.db.get("heirs", {})
|
||||||
try:
|
try:
|
||||||
self.update(d)
|
self.update(d)
|
||||||
@@ -400,6 +393,9 @@ class Heirs(dict, Logger):
|
|||||||
amount = 0
|
amount = 0
|
||||||
for key, v in heir_list.items():
|
for key, v in heir_list.items():
|
||||||
try:
|
try:
|
||||||
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
|
heir_list[key].insert(HEIR_REAL_AMOUNT, 0)
|
||||||
|
continue
|
||||||
column = HEIR_AMOUNT
|
column = HEIR_AMOUNT
|
||||||
if real:
|
if real:
|
||||||
column = HEIR_REAL_AMOUNT
|
column = HEIR_REAL_AMOUNT
|
||||||
@@ -451,6 +447,12 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if is_op_return_address(self[key][HEIR_ADDRESS]):
|
||||||
|
heir = list(self[key])
|
||||||
|
heir.insert(HEIR_REAL_AMOUNT, 0)
|
||||||
|
fixed_heirs[key] = heir
|
||||||
|
_logger.debug(f"OP_RETURN heir {key} excluded from amount calculation")
|
||||||
|
continue
|
||||||
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
if Util.is_perc(self[key][HEIR_AMOUNT]):
|
||||||
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
percent_amount += float(self[key][HEIR_AMOUNT][:-1])
|
||||||
percent_heirs[key] = list(self[key])
|
percent_heirs[key] = list(self[key])
|
||||||
@@ -478,7 +480,8 @@ class Heirs(dict, Logger):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def prepare_lists(
|
def prepare_lists(
|
||||||
self, balance, total_fees, wallet, willexecutor=False, from_locktime=0
|
self, balance, total_fees, wallet, willexecutor: Optional[dict] = None,
|
||||||
|
from_locktime=0, max_fee=None,
|
||||||
):
|
):
|
||||||
if balance<total_fees or balance < wallet.dust_threshold():
|
if balance<total_fees or balance < wallet.dust_threshold():
|
||||||
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
raise BalanceTooLowException(balance,wallet.dust_threshold(),total_fees)
|
||||||
@@ -493,8 +496,12 @@ class Heirs(dict, Logger):
|
|||||||
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
if int(Util.int_locktime(locktime)) > int(from_locktime):
|
||||||
try:
|
try:
|
||||||
base_fee = int(willexecutor["base_fee"])
|
base_fee = int(willexecutor["base_fee"])
|
||||||
|
if max_fee is not None and base_fee > max_fee:
|
||||||
|
raise WillExecutorFeeTooHighException(
|
||||||
|
willexecutor, max_fee
|
||||||
|
)
|
||||||
willexecutors_amount += base_fee
|
willexecutors_amount += base_fee
|
||||||
h = [None] * 4
|
h: list = [None] * 4
|
||||||
h[HEIR_AMOUNT] = base_fee
|
h[HEIR_AMOUNT] = base_fee
|
||||||
h[HEIR_REAL_AMOUNT] = base_fee
|
h[HEIR_REAL_AMOUNT] = base_fee
|
||||||
h[HEIR_LOCKTIME] = locktime
|
h[HEIR_LOCKTIME] = locktime
|
||||||
@@ -586,6 +593,8 @@ class Heirs(dict, Logger):
|
|||||||
heir[HEIR_REAL_AMOUNT]
|
heir[HEIR_REAL_AMOUNT]
|
||||||
):
|
):
|
||||||
valid_real_heirs += 1
|
valid_real_heirs += 1
|
||||||
|
elif len(heir) > HEIR_REAL_AMOUNT and is_op_return_address(heir[HEIR_ADDRESS]):
|
||||||
|
valid_real_heirs += 1
|
||||||
if real_heirs > 0 and valid_real_heirs == 0:
|
if real_heirs > 0 and valid_real_heirs == 0:
|
||||||
raise HeirAmountIsDustException(
|
raise HeirAmountIsDustException(
|
||||||
"All heirs' shares are below the dust limit"
|
"All heirs' shares are below the dust limit"
|
||||||
@@ -599,8 +608,35 @@ class Heirs(dict, Logger):
|
|||||||
def buildTransactions(
|
def buildTransactions(
|
||||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
||||||
):
|
):
|
||||||
Heirs._validate(self)
|
# Reset the diagnostic reason at the start of every build attempt.
|
||||||
|
#
|
||||||
|
# WHY: when the build produced nothing, the GUI used to show a fixed
|
||||||
|
# list of three "possible reasons" (low balance / dust shares /
|
||||||
|
# check-alive after the delivery date). In practice the real cause is
|
||||||
|
# often NONE of those three - several code paths below simply return
|
||||||
|
# an empty result with no explanation at all, so the user was shown
|
||||||
|
# three guesses that were all wrong. Each such path now records WHY
|
||||||
|
# it gave up, and BalBuildWillDialog names the actual cause.
|
||||||
|
#
|
||||||
|
# Codes: NO_HEIRS, NO_UTXO, NO_WILLEXECUTOR_USABLE, NO_FUTURE_DATE,
|
||||||
|
# WILLEXECUTOR_FEE, WILLEXECUTOR_FEE_TOO_HIGH, TX_BUILD_FAILED,
|
||||||
|
# WILLEXECUTOR_TX_ERROR.
|
||||||
|
self.last_build_error = None
|
||||||
|
_before = list(self.keys())
|
||||||
|
Heirs._validate(self, persist=False)
|
||||||
|
_removed = [k for k in _before if k not in self]
|
||||||
|
if _removed:
|
||||||
|
# The build skips invalid heirs, but they are only dropped in memory
|
||||||
|
# (persist=False): the wallet still keeps them, so the user can fix
|
||||||
|
# or remove them deliberately instead of losing them silently.
|
||||||
|
_logger.warning(
|
||||||
|
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
|
||||||
|
"not removed): %s",
|
||||||
|
len(_removed),
|
||||||
|
", ".join(_removed),
|
||||||
|
)
|
||||||
if len(self) <= 0:
|
if len(self) <= 0:
|
||||||
|
self.last_build_error = "NO_HEIRS"
|
||||||
_logger.info("while building transactions there was no heirs")
|
_logger.info("while building transactions there was no heirs")
|
||||||
return
|
return
|
||||||
balance = 0.0
|
balance = 0.0
|
||||||
@@ -612,35 +648,43 @@ class Heirs(dict, Logger):
|
|||||||
self.decimal_point = bal_plugin.get_decimal_point()
|
self.decimal_point = bal_plugin.get_decimal_point()
|
||||||
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
no_willexecutors = bal_plugin.NO_WILLEXECUTOR.get()
|
||||||
for utxo in utxos:
|
for utxo in utxos:
|
||||||
if utxo.value_sats() > 0 * tx_fees:
|
if utxo.value_sats() > 0:
|
||||||
balance += utxo.value_sats()
|
balance += utxo.value_sats()
|
||||||
len_utxo_set += 1
|
len_utxo_set += 1
|
||||||
available_utxos.append(utxo)
|
available_utxos.append(utxo)
|
||||||
if len_utxo_set == 0:
|
if len_utxo_set == 0:
|
||||||
|
self.last_build_error = "NO_UTXO"
|
||||||
_logger.info("no usable utxos")
|
_logger.info("no usable utxos")
|
||||||
return
|
return
|
||||||
j = -2
|
j = -2
|
||||||
willexecutorsitems = list(willexecutors.items())
|
willexecutorsitems = list(willexecutors.items())
|
||||||
willexecutorslen = len(willexecutorsitems)
|
willexecutorslen = len(willexecutorsitems)
|
||||||
alltxs = {}
|
alltxs = {}
|
||||||
|
# Counts how many will-executors were actually PROCESSED (i.e. passed
|
||||||
|
# the is_selected/is_valid filter below and reached the build loop).
|
||||||
|
# If it stays 0 the loop silently skipped every single one, which is a
|
||||||
|
# distinct failure from "we tried and the build failed".
|
||||||
|
processed_willexecutors = 0
|
||||||
while True:
|
while True:
|
||||||
j += 1
|
j += 1
|
||||||
if j >= willexecutorslen:
|
if j >= willexecutorslen:
|
||||||
break
|
break
|
||||||
elif 0 <= j:
|
elif 0 <= j:
|
||||||
url, willexecutor = willexecutorsitems[j]
|
url, willexecutor = willexecutorsitems[j]
|
||||||
if not Willexecutors.is_selected(willexecutor) or willexecutor["base_fee"] < wallet.dust_threshold():
|
if not (Willexecutors.is_selected(willexecutor) and Willexecutors.is_valid(willexecutor, max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(), dust=wallet.dust_threshold())):
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
willexecutor["url"] = url
|
willexecutor["url"] = url
|
||||||
elif j == -1:
|
elif j == -1:
|
||||||
if not no_willexecutors:
|
if not no_willexecutors:
|
||||||
continue
|
continue
|
||||||
url = willexecutor = False
|
url = willexecutor = None
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
processed_willexecutors += 1
|
||||||
fees = {}
|
fees = {}
|
||||||
i = 0
|
i = 0
|
||||||
|
txs = {}
|
||||||
while i < 10:
|
while i < 10:
|
||||||
txs = {}
|
txs = {}
|
||||||
redo = False
|
redo = False
|
||||||
@@ -651,9 +695,15 @@ class Heirs(dict, Logger):
|
|||||||
# newbalance = balance
|
# newbalance = balance
|
||||||
try:
|
try:
|
||||||
locktimes, onlyfixed = self.prepare_lists(
|
locktimes, onlyfixed = self.prepare_lists(
|
||||||
balance, total_fees, wallet, willexecutor, from_locktime
|
balance, total_fees, wallet, willexecutor, from_locktime,
|
||||||
|
max_fee=bal_plugin.MAX_WILLEXECUTOR_FEE.get(),
|
||||||
)
|
)
|
||||||
except WillExecutorFeeException:
|
except WillExecutorFeeException:
|
||||||
|
self.last_build_error = "WILLEXECUTOR_FEE"
|
||||||
|
i = 10
|
||||||
|
continue
|
||||||
|
except WillExecutorFeeTooHighException:
|
||||||
|
self.last_build_error = "WILLEXECUTOR_FEE_TOO_HIGH"
|
||||||
i = 10
|
i = 10
|
||||||
continue
|
continue
|
||||||
if locktimes:
|
if locktimes:
|
||||||
@@ -662,19 +712,33 @@ class Heirs(dict, Logger):
|
|||||||
locktimes, available_utxos[:], fees, wallet
|
locktimes, available_utxos[:], fees, wallet
|
||||||
)
|
)
|
||||||
if not txs:
|
if not txs:
|
||||||
|
self.last_build_error = "TX_BUILD_FAILED"
|
||||||
return {}
|
return {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# An unexpected failure while assembling the
|
||||||
|
# transactions for THIS will-executor.
|
||||||
|
#
|
||||||
|
# WHY THIS CHANGED: the previous code read
|
||||||
|
# ``e.heirname`` here, in order to auto-deselect the
|
||||||
|
# will-executor blamed by the exception. NOTHING in
|
||||||
|
# the plugin sets that attribute any more (it is a
|
||||||
|
# leftover from an older exception design), so the
|
||||||
|
# lookup itself raised AttributeError, and the inner
|
||||||
|
# ``except Exception: raise`` re-raised THAT - aborting
|
||||||
|
# the whole build with a confusing secondary error
|
||||||
|
# instead of the real one. We now record the reason,
|
||||||
|
# log the actual exception together with the
|
||||||
|
# will-executor it happened on, and simply move on to
|
||||||
|
# the next one, which is what the original code was
|
||||||
|
# clearly trying to do.
|
||||||
|
self.last_build_error = "WILLEXECUTOR_TX_ERROR"
|
||||||
_logger.error(
|
_logger.error(
|
||||||
f"build transactions: error preparing transactions: {e}"
|
"build transactions: error preparing transactions "
|
||||||
|
"for will-executor %s: %r",
|
||||||
|
(willexecutor or {}).get("url", "(none)"),
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
try:
|
break
|
||||||
if "w!ll3x3c" in e.heirname:
|
|
||||||
Willexecutors.is_selected(
|
|
||||||
e.heirname[len("w!ll3x3c") :], False
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
raise e
|
|
||||||
total_fees = 0
|
total_fees = 0
|
||||||
total_fees_real = 0
|
total_fees_real = 0
|
||||||
total_in = 0
|
total_in = 0
|
||||||
@@ -698,12 +762,26 @@ class Heirs(dict, Logger):
|
|||||||
if i >= 10:
|
if i >= 10:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
self.last_build_error = "NO_FUTURE_DATE"
|
||||||
_logger.info(
|
_logger.info(
|
||||||
f"no locktimes for willexecutor {willexecutor} skipped"
|
f"no locktimes for willexecutor {willexecutor} skipped"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
alltxs.update(txs)
|
alltxs.update(txs)
|
||||||
|
|
||||||
|
# Every will-executor was skipped by the is_selected/is_valid filter
|
||||||
|
# (or the list was empty) and no "no will-executor" build was allowed,
|
||||||
|
# so the loop above never even attempted a build. This path used to
|
||||||
|
# return silently with no log line at all, which is exactly the case
|
||||||
|
# the owner hit: the dialog then blamed balance/dust/check-alive, none
|
||||||
|
# of which was true.
|
||||||
|
if not alltxs and processed_willexecutors == 0:
|
||||||
|
self.last_build_error = "NO_WILLEXECUTOR_USABLE"
|
||||||
|
_logger.info(
|
||||||
|
"no usable will-executor: all %d skipped (not selected or not valid)",
|
||||||
|
willexecutorslen,
|
||||||
|
)
|
||||||
|
|
||||||
return alltxs
|
return alltxs
|
||||||
|
|
||||||
def get_transactions(
|
def get_transactions(
|
||||||
@@ -776,7 +854,7 @@ class Heirs(dict, Logger):
|
|||||||
# support email-style addresses, per the OA standard
|
# support email-style addresses, per the OA standard
|
||||||
url = url.replace("@", ".")
|
url = url.replace("@", ".")
|
||||||
try:
|
try:
|
||||||
records, validated = dnssec.query(url, dns.rdatatype.TXT)
|
records, validated = _query_txt_records(url)
|
||||||
except DNSException as e:
|
except DNSException as e:
|
||||||
_logger.info(f"Error resolving openalias: {repr(e)}")
|
_logger.info(f"Error resolving openalias: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
@@ -785,60 +863,78 @@ class Heirs(dict, Logger):
|
|||||||
string = to_string(record.strings[0], "utf8")
|
string = to_string(record.strings[0], "utf8")
|
||||||
if string.startswith("oa1:" + prefix):
|
if string.startswith("oa1:" + prefix):
|
||||||
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
address = cls.find_regex(string, r"recipient_address=([A-Za-z0-9]+)")
|
||||||
|
if not address:
|
||||||
|
continue
|
||||||
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
name = cls.find_regex(string, r"recipient_name=([^;]+)")
|
||||||
if not name:
|
if not name:
|
||||||
name = address
|
name = address
|
||||||
if not address:
|
|
||||||
continue
|
|
||||||
return address, name, validated
|
return address, name, validated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_regex(haystack, needle):
|
def find_regex(haystack, needle) -> Optional[str]:
|
||||||
regex = re.compile(needle)
|
regex = re.compile(needle)
|
||||||
try:
|
try:
|
||||||
return regex.search(haystack).groups()[0]
|
return regex.search(haystack).groups()[0]
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_address(address):
|
def validate_address(address):
|
||||||
|
if is_op_return_address(address):
|
||||||
|
data_hex = address[len(OP_RETURN_PREFIX):]
|
||||||
|
validate_op_return_hex(data_hex)
|
||||||
|
return address
|
||||||
if not bitcoin.is_address(address, net=constants.net):
|
if not bitcoin.is_address(address, net=constants.net):
|
||||||
raise NotAnAddress(f"not an address,{address}")
|
raise NotAnAddress(f"not an address,{address}")
|
||||||
return address
|
return address
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_amount(amount):
|
def validate_amount(amount):
|
||||||
try:
|
try:
|
||||||
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
famount = float(amount[:-1]) if Util.is_perc(amount) else float(amount)
|
||||||
if famount <= 0.00000001:
|
if famount <= 0.00000001:
|
||||||
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
raise AmountNotValid(f"amount have to be positive {famount} < 0")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise AmountNotValid(f"amount not properly formatted, {e}")
|
raise AmountNotValid(f"amount not properly formatted, {e}") from e
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_locktime(locktime, timestamp_to_check=False):
|
def validate_locktime(locktime, timestamp_to_check=False):
|
||||||
try:
|
try:
|
||||||
if timestamp_to_check:
|
if timestamp_to_check:
|
||||||
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
if Util.parse_locktime_string(locktime, None) < timestamp_to_check:
|
||||||
raise HeirExpiredException()
|
raise HeirExpiredException()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise LocktimeNotValid(f"locktime string not properly formatted, {e}")
|
raise LocktimeNotValid(f"locktime string not properly formatted, {e}") from e
|
||||||
return locktime
|
return locktime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def validate_heir(k, v, timestamp_to_check=False):
|
def validate_heir(k, v, timestamp_to_check=False):
|
||||||
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
address = Heirs.validate_address(v[HEIR_ADDRESS])
|
||||||
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
if is_op_return_address(v[HEIR_ADDRESS]):
|
||||||
|
amount = "0"
|
||||||
|
else:
|
||||||
|
amount = Heirs.validate_amount(v[HEIR_AMOUNT])
|
||||||
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
locktime = Heirs.validate_locktime(v[HEIR_LOCKTIME], timestamp_to_check)
|
||||||
return (address, amount, locktime)
|
return (address, amount, locktime)
|
||||||
|
|
||||||
def _validate(data, timestamp_to_check=False):
|
@staticmethod
|
||||||
|
def _validate(data, timestamp_to_check=False, persist=True):
|
||||||
|
|
||||||
for k, v in list(data.items()):
|
for k, v in list(data.items()):
|
||||||
if k == "heirs":
|
if k == "heirs":
|
||||||
return Heirs._validate(v, timestamp_to_check)
|
return Heirs._validate(v, timestamp_to_check, persist)
|
||||||
try:
|
try:
|
||||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.info(f"exception heir removed {e}")
|
_logger.info(f"exception heir removed {e}")
|
||||||
data.pop(k)
|
if persist:
|
||||||
|
data.pop(k)
|
||||||
|
else:
|
||||||
|
# Drop the invalid heir in memory only, so the overridden
|
||||||
|
# Heirs.pop (which calls save()) is not triggered: a build
|
||||||
|
# must not silently delete heirs from the wallet.
|
||||||
|
dict.pop(data, k)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -874,6 +970,19 @@ class WillExecutorFeeException(Exception):
|
|||||||
return "WillExecutorFeeException: {} fee:{}".format(
|
return "WillExecutorFeeException: {} fee:{}".format(
|
||||||
self.willexecutor["url"], self.willexecutor["base_fee"]
|
self.willexecutor["url"], self.willexecutor["base_fee"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class WillExecutorFeeTooHighException(Exception):
|
||||||
|
def __init__(self, willexecutor, max_fee):
|
||||||
|
self.willexecutor = willexecutor
|
||||||
|
self.max_fee = max_fee
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "WillExecutorFeeTooHighException: {} fee:{} > max:{}".format(
|
||||||
|
self.willexecutor["url"],
|
||||||
|
self.willexecutor["base_fee"],
|
||||||
|
self.max_fee,
|
||||||
|
)
|
||||||
|
|
||||||
class BalanceTooLowException(Exception):
|
class BalanceTooLowException(Exception):
|
||||||
def __init__(self,balance, dust_threshold, fees):
|
def __init__(self,balance, dust_threshold, fees):
|
||||||
self.balance=balance
|
self.balance=balance
|
||||||
|
|||||||
206
bal/core/input_rules.py
Normal file
206
bal/core/input_rules.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
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, allow_negative: bool = False
|
||||||
|
) -> 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``.
|
||||||
|
allow_negative: when ``True`` a single leading ``-`` is kept (used by
|
||||||
|
the Check Alive field, whose sign selects the anchor date). The
|
||||||
|
delivery (locktime) field never accepts a sign.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
|
||||||
|
text and ``new_pos`` the adjusted cursor position.
|
||||||
|
"""
|
||||||
|
text = text.strip()
|
||||||
|
chars = "0123456789dy"
|
||||||
|
if allow_negative:
|
||||||
|
chars += "-"
|
||||||
|
pos = len("".join([i for i in text[:pos] if i in chars]))
|
||||||
|
s = "".join([i for i in text if i in chars])
|
||||||
|
isdays = False
|
||||||
|
isyears = False
|
||||||
|
|
||||||
|
negative = allow_negative and "-" in s
|
||||||
|
if negative:
|
||||||
|
s = s.replace("-", "")
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
if negative:
|
||||||
|
s = "-" + s
|
||||||
|
pos = max(pos, 1)
|
||||||
|
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,18 +21,62 @@ 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 datetime, timedelta, timezone
|
||||||
|
|
||||||
from electrum import constants, json_db
|
from electrum import constants, json_db
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.plugin import BasePlugin
|
from electrum.plugin import BasePlugin
|
||||||
from electrum.transaction import tx_from_any
|
from electrum.transaction import tx_from_any
|
||||||
|
from electrum.util import classproperty
|
||||||
|
|
||||||
|
from ..i18n import N_, _
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Plugin version - single source of truth
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# The version lives ONLY in bal/manifest.json (the file Electrum itself reads).
|
||||||
|
# We used to hardcode it in four files and keep them in sync with a pre-commit
|
||||||
|
# hook; reading it from the manifest removes that duplication.
|
||||||
|
#
|
||||||
|
# importlib.resources is used on purpose: it reads a data file bundled inside
|
||||||
|
# the ``bal`` package and works identically whether the plugin runs from an
|
||||||
|
# extracted directory or from INSIDE a zip (Electrum loads external plugins via
|
||||||
|
# zipimport). It never builds a path by hand, so there is no os.path.join
|
||||||
|
# backslash issue on Windows inside a zip.
|
||||||
|
_VERSION_CACHE = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_version():
|
||||||
|
"""Return the plugin version from ``bal/manifest.json`` (cached).
|
||||||
|
|
||||||
|
Zip-safe and independent of the current working directory. Falls back to
|
||||||
|
``"unknown"`` if the manifest cannot be read, so importing the plugin never
|
||||||
|
fails just because of version lookup.
|
||||||
|
"""
|
||||||
|
global _VERSION_CACHE
|
||||||
|
if _VERSION_CACHE is None:
|
||||||
|
try:
|
||||||
|
import importlib.resources
|
||||||
|
|
||||||
|
_parent_pkg = __package__.rpartition(".")[0] if __package__ else "bal"
|
||||||
|
data = (
|
||||||
|
importlib.resources.files(_parent_pkg)
|
||||||
|
.joinpath("manifest.json")
|
||||||
|
.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
_VERSION_CACHE = json.loads(data)["version"]
|
||||||
|
except Exception as e: # noqa: BLE001 - never break import over version
|
||||||
|
_logger.error(f"failed to read version from manifest.json: {e}")
|
||||||
|
_VERSION_CACHE = "unknown"
|
||||||
|
return _VERSION_CACHE
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Wallet-DB registration
|
# Wallet-DB registration
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -67,7 +111,9 @@ def get_will(x):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Electrum >= 4.8.0
|
# Electrum >= 4.8.0
|
||||||
from electrum.stored_dict import register_name as _electrum_register_name
|
from electrum.stored_dict import (
|
||||||
|
register_name as _electrum_register_name, # pyright: ignore[reportMissingImports]
|
||||||
|
)
|
||||||
|
|
||||||
def _register_will_dict(name, method, _type=None):
|
def _register_will_dict(name, method, _type=None):
|
||||||
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
"""Register a plugin dict in the wallet DB (Electrum >= 4.8.0 API)."""
|
||||||
@@ -77,7 +123,7 @@ except ImportError:
|
|||||||
# Electrum <= 4.7.2
|
# Electrum <= 4.7.2
|
||||||
def _register_will_dict(name, method, _type=None):
|
def _register_will_dict(name, method, _type=None):
|
||||||
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
|
"""Register a plugin dict in the wallet DB (Electrum <= 4.7.2 API)."""
|
||||||
json_db.register_dict(name, method, _type)
|
json_db.register_dict(name, method, _type) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
|
||||||
_register_will_dict("heirs", tuple)
|
_register_will_dict("heirs", tuple)
|
||||||
@@ -90,12 +136,20 @@ class BalConfig:
|
|||||||
|
|
||||||
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
Wraps ``config.get`` / ``config.set_key`` and supplies a default value
|
||||||
when the key is missing.
|
when the key is missing.
|
||||||
|
|
||||||
|
``translatable=True`` is for a default *text* (marked with ``N_()``) that
|
||||||
|
should follow the GUI language. The English default is what gets stored,
|
||||||
|
and :meth:`get` translates it when it is read, so the stored value never
|
||||||
|
depends on the GUI language. A text the user wrote is returned unchanged.
|
||||||
|
Only for texts that are shown: a text that is also used to recognise
|
||||||
|
stored data (e.g. ``HISTORY_LABEL``) must stay non-translatable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config, name, default):
|
def __init__(self, config, name, default, translatable=False):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.name = name
|
self.name = name
|
||||||
self.default = default
|
self.default = default
|
||||||
|
self.translatable = translatable
|
||||||
|
|
||||||
def get(self, default=None):
|
def get(self, default=None):
|
||||||
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
"""Return the stored value, falling back to ``default`` then ``self.default``."""
|
||||||
@@ -105,10 +159,20 @@ class BalConfig:
|
|||||||
v = default
|
v = default
|
||||||
else:
|
else:
|
||||||
v = self.default
|
v = self.default
|
||||||
|
if self.translatable and v == self.default:
|
||||||
|
return _(v)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
def localized_default(self):
|
||||||
|
"""Return the default value, translated if the setting is translatable."""
|
||||||
|
return _(self.default) if self.translatable else self.default
|
||||||
|
|
||||||
def set(self, value, save=True):
|
def set(self, value, save=True):
|
||||||
"""Persist ``value`` for this key."""
|
"""Persist ``value`` for this key."""
|
||||||
|
if self.translatable and value == self.localized_default():
|
||||||
|
# The translated default is stored as its English original, so
|
||||||
|
# it keeps following the GUI language (see get()).
|
||||||
|
value = self.default
|
||||||
self.config.set_key(self.name, value, save=save)
|
self.config.set_key(self.name, value, save=save)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,9 +184,6 @@ class BalPlugin(BasePlugin):
|
|||||||
layer (or unit tests) can use the plugin logic without importing Qt.
|
layer (or unit tests) can use the plugin logic without importing Qt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_version = None
|
|
||||||
__version__ = "0.5.18" # AUTOMATICALLY GENERATED DO NOT EDIT
|
|
||||||
|
|
||||||
# Command used to open an .ics calendar file, per operating system.
|
# Command used to open an .ics calendar file, per operating system.
|
||||||
default_app = {
|
default_app = {
|
||||||
"Linux": "xdg-open",
|
"Linux": "xdg-open",
|
||||||
@@ -131,25 +192,21 @@ class BalPlugin(BasePlugin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
# Human-readable chain name ("bitcoin", "testnet", "regtest", ...).
|
||||||
chainname = (
|
# Must be a classproperty (not a plain class attribute) because the class
|
||||||
constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
# is defined before constants.net is set to the correct network — a plain
|
||||||
)
|
# attribute would capture "bitcoin" and never update.
|
||||||
|
@classproperty
|
||||||
|
def chainname(cls):
|
||||||
|
return constants.net.NET_NAME if constants.net.NET_NAME != "mainnet" else "bitcoin"
|
||||||
|
|
||||||
# Default geometry hint for some dialogs (kept from the original code).
|
# Default geometry hint for some dialogs (kept from the original code).
|
||||||
SIZE = (159, 97)
|
SIZE = (159, 97)
|
||||||
|
|
||||||
|
@property
|
||||||
def version(self):
|
def version(self):
|
||||||
"""Return the plugin version, read once from the ``VERSION`` file."""
|
"""Plugin version, read from ``bal/manifest.json`` (single source of
|
||||||
if not self._version:
|
truth). See :func:`get_version`."""
|
||||||
try:
|
return get_version()
|
||||||
f = ""
|
|
||||||
with open("{}/VERSION".format(self.plugin_dir), "r") as fi:
|
|
||||||
f = str(fi.read())
|
|
||||||
self._version = f.strip()
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"failed to get version: {e}")
|
|
||||||
self._version = "unknown"
|
|
||||||
return self._version
|
|
||||||
|
|
||||||
def __init__(self, parent, config, name):
|
def __init__(self, parent, config, name):
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
@@ -197,6 +254,25 @@ 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.
|
||||||
|
# Deliberately NOT translatable: Util._label_matches_history() uses
|
||||||
|
# this text to recognise BAL's own local transactions (stale-history
|
||||||
|
# cleanup, spendable UTXOs). A label that changed with the GUI
|
||||||
|
# language would no longer match the transactions saved before.
|
||||||
|
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
|
||||||
@@ -205,12 +281,36 @@ class BalPlugin(BasePlugin):
|
|||||||
# (handled by BalWindow.get_wallet_password). Default ON.
|
# (handled by BalWindow.get_wallet_password). Default ON.
|
||||||
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
|
self.AUTO_SIGN = BalConfig(config, "bal_auto_sign", True)
|
||||||
|
|
||||||
|
# REBUILD_ON_CLOSE: when enabled (default), closing the wallet or
|
||||||
|
# quitting Electrum runs the "Build your will" wizard
|
||||||
|
# (BalBuildWillDialog) to rebuild and re-validate the will. When
|
||||||
|
# disabled, on_close() only persists the current in-memory willitems to
|
||||||
|
# the wallet DB: no rebuild dialog, no auto-sign/broadcast, no
|
||||||
|
# invalidation prompts at close. Default ON.
|
||||||
|
self.REBUILD_ON_CLOSE = BalConfig(config, "bal_rebuild_on_close", True)
|
||||||
|
|
||||||
|
# AUTO_REBUILD: when enabled, an incoming/outgoing wallet transaction
|
||||||
|
# automatically re-runs the same rebuild flow the wizard runs at
|
||||||
|
# wallet close (anticipate the delivery date by one day to orphan the
|
||||||
|
# previous will; build an on-chain invalidation tx ONLY when the
|
||||||
|
# anticipated locktime would fall before the check-alive threshold or
|
||||||
|
# the threshold is already in the past). When disabled (default) the
|
||||||
|
# will is only rebuilt when the user presses Check / Prepare or closes
|
||||||
|
# the wallet. Default OFF.
|
||||||
|
self.AUTO_REBUILD = BalConfig(config, "bal_auto_rebuild", False)
|
||||||
|
|
||||||
# EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
|
# EDITABLE_DATES (Group C / C2): when enabled, the delivery-time and
|
||||||
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
|
# check-alive date fields are editable everywhere (toolbar / Heirs tab),
|
||||||
# not only inside the "Build your will" wizard. Default OFF, so the dates
|
# not only inside the "Build your will" wizard. Default OFF, so the dates
|
||||||
# stay display-only outside the wizard unless the user opts in.
|
# stay display-only outside the wizard unless the user opts in.
|
||||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||||
|
|
||||||
|
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in characters,
|
||||||
|
# used per QR frame when exporting/importing a will through the QR
|
||||||
|
# channel. The export dialog seeds its own size selector from this
|
||||||
|
# value (default 150, small QR, low-resolution cameras).
|
||||||
|
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||||
|
|
||||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||||
# exported .ics calendar should contain. Each reminder becomes its own
|
# exported .ics calendar should contain. Each reminder becomes its own
|
||||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||||
@@ -228,6 +328,9 @@ class BalPlugin(BasePlugin):
|
|||||||
# follows what is saved in that wallet (the default only applies when no
|
# follows what is saved in that wallet (the default only applies when no
|
||||||
# value has been stored yet, i.e. new wallets).
|
# value has been stored yet, i.e. new wallets).
|
||||||
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
|
self.NO_WILLEXECUTOR = BalConfig(config, "bal_no_willexecutor", False)
|
||||||
|
self.MAX_WILLEXECUTOR_FEE = BalConfig(
|
||||||
|
config, "bal_max_willexecutor_fee", 500000
|
||||||
|
)
|
||||||
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
self.HIDE_REPLACED = BalConfig(config, "bal_hide_replaced", True)
|
||||||
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
self.HIDE_INVALIDATED = BalConfig(config, "bal_hide_invalidated", True)
|
||||||
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
self.ALLOW_REPUSH = BalConfig(config, "bal_allow_repush", True)
|
||||||
@@ -248,16 +351,26 @@ class BalPlugin(BasePlugin):
|
|||||||
self.WELIST_SERVER = BalConfig(
|
self.WELIST_SERVER = BalConfig(
|
||||||
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
config, "bal_welist_server", "https://welist.bitcoin-after.life/"
|
||||||
)
|
)
|
||||||
|
# Calendar (.ics) texts: only shown, never used to recognise data, so
|
||||||
|
# their defaults follow the GUI language (translatable=True). The
|
||||||
|
# $tokens must survive translation.
|
||||||
self.EVENT_DESCRIPTION = BalConfig(
|
self.EVENT_DESCRIPTION = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_event_description",
|
"bal_event_description",
|
||||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
N_("BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete"),
|
||||||
|
translatable=True,
|
||||||
)
|
)
|
||||||
self.EVENT_SUMMARY = BalConfig(
|
self.EVENT_SUMMARY = BalConfig(
|
||||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
config,
|
||||||
|
"bal_event_summary",
|
||||||
|
N_("BAL -Will execution of $wallet_name"),
|
||||||
|
translatable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default will-executor servers, keyed by network.
|
# Default will-executor servers, keyed by network. These addresses are
|
||||||
|
# the ones currently reported by each server's <chain>/info endpoint and
|
||||||
|
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
|
||||||
|
# (bcrt1...) addresses, which are invalid on those networks.
|
||||||
self.WILLEXECUTORS = BalConfig(
|
self.WILLEXECUTORS = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_willexecutors",
|
"bal_willexecutors",
|
||||||
@@ -276,7 +389,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -285,7 +398,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -356,7 +469,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"""Fill in any missing will-setting with its default value."""
|
"""Fill in any missing will-setting with its default value."""
|
||||||
defaults = BalPlugin.default_will_settings()
|
defaults = BalPlugin.default_will_settings()
|
||||||
if not will_settings:
|
if not will_settings:
|
||||||
will_settings = []
|
will_settings = {}
|
||||||
if int(will_settings.get("baltx_fees", 0)) < 1:
|
if int(will_settings.get("baltx_fees", 0)) < 1:
|
||||||
will_settings["baltx_fees"] = defaults['baltx_fees']
|
will_settings["baltx_fees"] = defaults['baltx_fees']
|
||||||
if not will_settings.get("threshold"):
|
if not will_settings.get("threshold"):
|
||||||
@@ -378,28 +491,41 @@ class BalPlugin(BasePlugin):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def default_will_settings():
|
def default_will_settings():
|
||||||
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
"""Default will settings: a fee rate plus absolute threshold/locktime."""
|
||||||
will_settings = {"baltx_fees": 20}
|
will_settings: dict[str, float] = {"baltx_fees": 20}
|
||||||
will_settings.update(BalPlugin.default_will_settings_absolute())
|
will_settings.update(BalPlugin.default_will_settings_absolute())
|
||||||
return will_settings
|
return will_settings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_will_settings_absolute():
|
def default_will_settings_absolute():
|
||||||
"""Convert the default relative dates into absolute timestamps (from today)."""
|
"""Convert the default relative dates into absolute timestamps.
|
||||||
|
|
||||||
|
The Check Alive anchor follows its sign (see ``bal.core.checkalive``):
|
||||||
|
a negative default counts back from the delivery, a positive one counts
|
||||||
|
forward from today (the inheritance creation date).
|
||||||
|
"""
|
||||||
relative_dates = BalPlugin.default_will_settings_relative()
|
relative_dates = BalPlugin.default_will_settings_relative()
|
||||||
today = date.today()
|
today = datetime.now(tz=timezone.utc).date()
|
||||||
dt = datetime(today.year, today.month, today.day, 0, 0, 0)
|
dt = datetime(today.year, today.month, today.day, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
threshold = (
|
locktime_dt = dt + timedelta(
|
||||||
dt + timedelta(days=BalTimestamp(relative_dates["threshold"]).duration_to_days())
|
days=BalTimestamp(relative_dates["locktime"]).duration_to_days()
|
||||||
).timestamp()
|
)
|
||||||
locktime = (
|
threshold = BalTimestamp(relative_dates["threshold"])
|
||||||
dt + timedelta(days=BalTimestamp(relative_dates["locktime"]).duration_to_days())
|
if threshold.unit is not None and threshold.value < 0:
|
||||||
).timestamp()
|
anchor_dt = locktime_dt
|
||||||
return {"threshold": threshold, "locktime": locktime}
|
else:
|
||||||
|
anchor_dt = dt
|
||||||
|
threshold_dt = anchor_dt + timedelta(days=threshold.duration_to_days())
|
||||||
|
return {"threshold": threshold_dt.timestamp(), "locktime": locktime_dt.timestamp()}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_will_settings_relative():
|
def default_will_settings_relative():
|
||||||
"""Default relative dates: 30 days threshold, 1 year locktime."""
|
"""Default relative dates: 30 days BEFORE the delivery, 1 year locktime.
|
||||||
return {"threshold": "30d", "locktime": "1y"}
|
|
||||||
|
The Check Alive default is NEGATIVE on purpose: it keeps counting back
|
||||||
|
from the delivery date, preserving the historical "check alive 30 days
|
||||||
|
before delivery" behaviour.
|
||||||
|
"""
|
||||||
|
return {"threshold": "-30d", "locktime": "1y"}
|
||||||
|
|
||||||
|
|
||||||
class BalTimestamp:
|
class BalTimestamp:
|
||||||
@@ -411,10 +537,12 @@ class BalTimestamp:
|
|||||||
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
* an integer -> an absolute UNIX timestamp (``unit is None``)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value = None
|
value: int
|
||||||
unit = None
|
unit: str | None
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
|
self.value = 1
|
||||||
|
self.unit = None
|
||||||
str_value = str(value)
|
str_value = str(value)
|
||||||
if str_value and str_value[-1].lower() in ("y", "d"):
|
if str_value and str_value[-1].lower() in ("y", "d"):
|
||||||
self.value = int(str_value[:-1])
|
self.value = int(str_value[:-1])
|
||||||
@@ -443,14 +571,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, tz=timezone.utc)
|
||||||
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), tz=timezone.utc)
|
||||||
except (OSError, OverflowError, ValueError):
|
except (OSError, OverflowError, ValueError):
|
||||||
return datetime.fromtimestamp(INT32_MAX)
|
return datetime.fromtimestamp(int32_max, tz=timezone.utc)
|
||||||
|
|
||||||
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``.
|
||||||
@@ -463,7 +591,7 @@ class BalTimestamp:
|
|||||||
return self._safe_fromtimestamp(self.value)
|
return self._safe_fromtimestamp(self.value)
|
||||||
else:
|
else:
|
||||||
if from_date is None:
|
if from_date is None:
|
||||||
from_date = datetime.now()
|
from_date = datetime.now(tz=timezone.utc)
|
||||||
if isinstance(from_date, (int, float)):
|
if isinstance(from_date, (int, float)):
|
||||||
from_date = self._safe_fromtimestamp(from_date)
|
from_date = self._safe_fromtimestamp(from_date)
|
||||||
reverse = 1 if not reverse else -1
|
reverse = 1 if not reverse else -1
|
||||||
|
|||||||
80
bal/core/qrtransfer.py
Normal file
80
bal/core/qrtransfer.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
bal.core.qrtransfer
|
||||||
|
===================
|
||||||
|
|
||||||
|
GUI-free helpers for moving BAL will data between devices via QR codes or
|
||||||
|
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
|
||||||
|
|
||||||
|
Scope
|
||||||
|
-----
|
||||||
|
* converts will transactions into a compact ``transfer_string``
|
||||||
|
(newline-joined serialized transactions, optionally zlib + base64
|
||||||
|
compressed). The transfer string is the payload carried by BBQR QR frames
|
||||||
|
(``bal.core.animated_qr``).
|
||||||
|
|
||||||
|
The audio-modem channel carries the whole transfer string in a single blob
|
||||||
|
(PLAN_QR_TRANSFER.md section 4.4), so callers only use
|
||||||
|
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||||
|
|
||||||
|
This module never imports Qt or any Electrum GUI code (house rule).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
# 4 standard presets (label, payload budget per QR). Ordered from
|
||||||
|
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||||
|
CHUNK_PRESETS = (
|
||||||
|
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||||
|
("Medium - ~400 bytes/QR", 400),
|
||||||
|
("Large - ~900 bytes/QR", 900),
|
||||||
|
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_transfer(tx_strings, compress=False):
|
||||||
|
"""Join serialized transaction strings into a transfer string.
|
||||||
|
|
||||||
|
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||||
|
the whole bundle shrinks before being printed/scanned.
|
||||||
|
"""
|
||||||
|
return __compress("\n".join(tx_strings), enabled=compress)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_transfer(transfer_string, compressed):
|
||||||
|
"""Inverse of :func:`encode_transfer`.
|
||||||
|
|
||||||
|
Returns the list of serialized transaction strings; empty frames are
|
||||||
|
dropped so a trailing newline (or an empty payload) cannot produce an
|
||||||
|
empty trailing element.
|
||||||
|
"""
|
||||||
|
text = __decompress(transfer_string, enabled=compressed)
|
||||||
|
return [part for part in text.split("\n") if part]
|
||||||
|
|
||||||
|
|
||||||
|
def preset_index_for_chunk_size(chunk_size):
|
||||||
|
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||||
|
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||||
|
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||||
|
diff = abs(chunk_size - budget)
|
||||||
|
if diff < best_diff:
|
||||||
|
best, best_diff = index, diff
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Internals
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def __compress(text, *, enabled):
|
||||||
|
if not enabled:
|
||||||
|
return text
|
||||||
|
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def __decompress(text, *, enabled):
|
||||||
|
if not enabled:
|
||||||
|
return text
|
||||||
|
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||||
235
bal/core/reminders.py
Normal file
235
bal/core/reminders.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
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. ``[30, 16, 1]`` 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,
|
||||||
|
reminder_suffix: str = "(reminder {idx}/{total})",
|
||||||
|
) -> 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).
|
||||||
|
reminder_suffix: text appended to each event summary, with the
|
||||||
|
``{idx}`` and ``{total}`` fields. This module stays free of
|
||||||
|
Electrum imports, so the GUI passes it already translated; the
|
||||||
|
English default keeps the CLI output unchanged.
|
||||||
|
|
||||||
|
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(
|
||||||
|
"{} {}".format(summary_base, reminder_suffix.format(idx=idx, total=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"
|
||||||
267
bal/core/util.py
267
bal/core/util.py
@@ -18,10 +18,14 @@ original implementation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
|
from electrum.logging import get_logger
|
||||||
from electrum.transaction import PartialTxOutput
|
from electrum.transaction import PartialTxOutput
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
# Bitcoin consensus rule: an nLockTime value strictly below this threshold is
|
||||||
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
# interpreted as a *block height*, otherwise it is interpreted as a *UNIX
|
||||||
# timestamp*.
|
# timestamp*.
|
||||||
@@ -34,6 +38,41 @@ from electrum.transaction import PartialTxOutput
|
|||||||
LOCKTIME_THRESHOLD = 500000000
|
LOCKTIME_THRESHOLD = 500000000
|
||||||
|
|
||||||
|
|
||||||
|
def copy_structure(value, _path="copy"):
|
||||||
|
"""Return a JSON-serializable deep copy of *value*.
|
||||||
|
|
||||||
|
This is the ad-hoc, deepcopy-free stand-in used every time the plugin needs
|
||||||
|
an independent copy of a plain-data structure (heirs dicts, will-executor
|
||||||
|
dicts, status tables). It recursively clones dict / list / tuple values
|
||||||
|
while leaving JSON scalars (str / int / float / bool / None) as-is.
|
||||||
|
|
||||||
|
If any nested element is a live runtime object (e.g. one holding a
|
||||||
|
``threading.RLock``), ``copy.deepcopy`` would raise
|
||||||
|
``TypeError: cannot pickle '_thread.RLock' object``; instead we coerce the
|
||||||
|
offending value to ``str(value)`` and log it with its path so the source
|
||||||
|
field can be identified, without crashing the caller.
|
||||||
|
"""
|
||||||
|
# Primitive JSON scalars are kept as-is.
|
||||||
|
if value is None or isinstance(value, (bool, int, float, str)):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
str(k): copy_structure(v, "{}[{!r}]".format(_path, k))
|
||||||
|
for k, v in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [
|
||||||
|
copy_structure(v, "{}[{}]".format(_path, i)) for i, v in enumerate(value)
|
||||||
|
]
|
||||||
|
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
||||||
|
# was found so the real source can be fixed, then store a safe string.
|
||||||
|
_logger.error(
|
||||||
|
"copy_structure: non-serializable value at {} (type={}); coercing to "
|
||||||
|
"str. value={!r}".format(_path, type(value).__name__, value)
|
||||||
|
)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
class Util:
|
class Util:
|
||||||
"""Namespace of static helpers (kept as a class to preserve the original
|
"""Namespace of static helpers (kept as a class to preserve the original
|
||||||
``Util.method(...)`` call sites used throughout the plugin)."""
|
``Util.method(...)`` call sites used throughout the plugin)."""
|
||||||
@@ -102,7 +141,7 @@ class Util:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
now = datetime.now()
|
now = datetime.now(tz=timezone.utc)
|
||||||
if locktime[-1] == "y":
|
if locktime[-1] == "y":
|
||||||
locktime = str(int(locktime[:-1]) * 365) + "d"
|
locktime = str(int(locktime[:-1]) * 365) + "d"
|
||||||
if locktime[-1] == "d":
|
if locktime[-1] == "d":
|
||||||
@@ -130,6 +169,72 @@ class Util:
|
|||||||
+ days * 60 * 60 * 24
|
+ days * 60 * 60 * 24
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relative_days(value):
|
||||||
|
"""Duration in days of a relative ``"Nd"``/``"Ny"`` recipe.
|
||||||
|
|
||||||
|
Returns ``None`` when the value is not a relative recipe (an absolute
|
||||||
|
timestamp, a plain number, or garbage).
|
||||||
|
"""
|
||||||
|
s = str(value)
|
||||||
|
if s and s[-1] in "yYdD":
|
||||||
|
try:
|
||||||
|
n = int(s[:-1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return n * 365 if s[-1] in "yY" else n
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_locktime_against_tx(current, built, tx_locktime):
|
||||||
|
"""Resolve a locktime recipe against the moment the signed tx was built.
|
||||||
|
|
||||||
|
A RELATIVE recipe stored in the wallet (``"1y"``/``"30d"``) is a moving
|
||||||
|
target: parsing it against *now* on every check drifts it one day per
|
||||||
|
day away from the fixed locktime frozen inside the signed Bitcoin
|
||||||
|
transaction, so an UNCHANGED will is mistaken for a POSTPONE and the
|
||||||
|
plugin asks to invalidate it every day (reported bug). This resolves
|
||||||
|
the current recipe against the build moment instead, recovered from the
|
||||||
|
signed transaction's locktime and the recipe that was actually frozen
|
||||||
|
at build time (``built``, the value stored in the will item):
|
||||||
|
|
||||||
|
build_moment = tx_locktime - duration(built)
|
||||||
|
expected = build_moment + duration(current)
|
||||||
|
|
||||||
|
An unchanged recipe therefore resolves to exactly ``tx_locktime``
|
||||||
|
(coherent), a lengthened one resolves later (postpone) and a shortened
|
||||||
|
one earlier (anticipate).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current: the current locktime recipe (relative or absolute).
|
||||||
|
built: the recipe frozen at build time (stored in the will item).
|
||||||
|
tx_locktime: the absolute locktime frozen inside the signed tx.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: the resolved absolute locktime (UNIX timestamp).
|
||||||
|
"""
|
||||||
|
current_days = Util._relative_days(current)
|
||||||
|
built_days = Util._relative_days(built)
|
||||||
|
if current_days is None:
|
||||||
|
# Absolute current date: compare directly against the frozen tx.
|
||||||
|
try:
|
||||||
|
return int(current)
|
||||||
|
except Exception:
|
||||||
|
return Util.parse_locktime_string(current)
|
||||||
|
if built_days is None or not tx_locktime:
|
||||||
|
# The stored recipe was absolute (a fixed date) or the tx has no
|
||||||
|
# usable locktime: there is no relative anchor to recover the build
|
||||||
|
# moment, so fall back to the legacy forward-from-now resolution.
|
||||||
|
return Util.parse_locktime_string(current)
|
||||||
|
try:
|
||||||
|
base = datetime.fromtimestamp(int(tx_locktime), tz=timezone.utc).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
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -373,9 +478,9 @@ class Util:
|
|||||||
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
# On Windows datetime.fromtimestamp raises OverflowError past 2038
|
||||||
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
# (e.g. NLOCKTIME_MAX); clamp to INT32_MAX (Electrum issue #6170).
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromtimestamp(locktime)
|
dt = datetime.fromtimestamp(locktime, tz=timezone.utc)
|
||||||
except (OverflowError, OSError, ValueError):
|
except (OverflowError, OSError, ValueError):
|
||||||
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1))
|
dt = datetime.fromtimestamp(min(locktime, 2 ** 31 - 1), tz=timezone.utc)
|
||||||
dt -= timedelta(seconds=seconds)
|
dt -= timedelta(seconds=seconds)
|
||||||
out = dt.timestamp()
|
out = dt.timestamp()
|
||||||
|
|
||||||
@@ -383,34 +488,6 @@ class Util:
|
|||||||
out = 1
|
out = 1
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def cmp_locktime(locktimea, locktimeb):
|
|
||||||
"""Compare two relative locktime strings sharing the same unit."""
|
|
||||||
if locktimea == locktimeb:
|
|
||||||
return 0
|
|
||||||
strlocktimea = str(locktimea)
|
|
||||||
strlocktimeb = str(locktimeb)
|
|
||||||
if locktimea[-1] in "ydb":
|
|
||||||
if locktimeb[-1] == locktimea[-1]:
|
|
||||||
return int(strlocktimea[-1]) - int(strlocktimeb[-1])
|
|
||||||
else:
|
|
||||||
return int(locktimea) - (locktimeb)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_lowest_valid_tx(available_utxos, will):
|
|
||||||
"""Placeholder kept from the original code (sorts the will by locktime)."""
|
|
||||||
will = sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
|
||||||
for txid, willitem in will.items():
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_locktimes(will):
|
|
||||||
"""Return the distinct locktimes used by the transactions in ``will``."""
|
|
||||||
locktimes = {}
|
|
||||||
for txid, willitem in will.items():
|
|
||||||
locktimes[willitem["tx"].locktime] = True
|
|
||||||
return locktimes.keys()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_lowest_locktimes(locktimes):
|
def get_lowest_locktimes(locktimes):
|
||||||
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
"""Split a list of locktimes into (sorted_timestamps, sorted_blocks)."""
|
||||||
@@ -425,32 +502,6 @@ class Util:
|
|||||||
|
|
||||||
return sorted(sorted_timestamp), sorted(sorted_block)
|
return sorted(sorted_timestamp), sorted(sorted_block)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_lowest_locktimes_from_will(will):
|
|
||||||
"""Convenience wrapper: lowest locktimes directly from a will dict."""
|
|
||||||
return Util.get_lowest_locktimes(Util.get_locktimes(will))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def search_willtx_per_io(will, tx):
|
|
||||||
"""Find a will entry whose tx has the same inputs/outputs as ``tx``."""
|
|
||||||
for wid, w in will.items():
|
|
||||||
if Util.cmp_txs(w["tx"], tx["tx"]):
|
|
||||||
return wid, w
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def invalidate_will(will):
|
|
||||||
raise Exception("not implemented")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_will_spent_utxos(will):
|
|
||||||
"""Collect every input spent by any transaction in ``will``."""
|
|
||||||
utxos = []
|
|
||||||
for txid, willitem in will.items():
|
|
||||||
utxos += willitem["tx"].inputs()
|
|
||||||
|
|
||||||
return utxos
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# UTXO helpers
|
# UTXO helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -493,6 +544,106 @@ class Util:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_available_utxos(wallet, history_label, will_locktime=None):
|
||||||
|
"""Return the wallet's UTXOs as seen by the plugin's flows.
|
||||||
|
|
||||||
|
``wallet.get_utxos()`` drops any output that a wallet-LOCAL transaction
|
||||||
|
marks as spent. The plugin itself creates such local spenders when it
|
||||||
|
saves an incomplete will transaction into the local history; a *later*
|
||||||
|
will transaction stored there (a replacement/future will with a locktime
|
||||||
|
strictly after ``will_locktime``) must not hide the coins from the will
|
||||||
|
being checked or rebuilt. This view therefore restores those coins.
|
||||||
|
|
||||||
|
A local spender is ignored (the coin is kept available) only when ALL of
|
||||||
|
these hold:
|
||||||
|
|
||||||
|
* it is a wallet-local or future transaction (not broadcast),
|
||||||
|
* its wallet label matches the BAL history label template (after the
|
||||||
|
"{willexecutor}" substitution),
|
||||||
|
* the stored spender's locktime is strictly LATER than ``will_locktime``.
|
||||||
|
|
||||||
|
Real (broadcast/confirmed) spenders are never ignored. With a falsy
|
||||||
|
``will_locktime`` this returns ``wallet.get_utxos()`` unchanged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wallet: The Electrum wallet object.
|
||||||
|
history_label: The BAL history label template (may contain
|
||||||
|
"{willexecutor}").
|
||||||
|
will_locktime: Reference locktime of the will being operated on.
|
||||||
|
"""
|
||||||
|
if not wallet or not will_locktime:
|
||||||
|
return list(wallet.get_utxos()) if wallet else []
|
||||||
|
adb = getattr(wallet, "adb", None)
|
||||||
|
if adb is None or not hasattr(adb, "get_addr_outputs"):
|
||||||
|
return list(wallet.get_utxos())
|
||||||
|
addresses = (
|
||||||
|
wallet.get_addresses() if hasattr(wallet, "get_addresses") else []
|
||||||
|
)
|
||||||
|
utxos = []
|
||||||
|
for addr in addresses:
|
||||||
|
try:
|
||||||
|
outputs = adb.get_addr_outputs(addr)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for utxo in outputs.values():
|
||||||
|
if utxo.spent_height is None:
|
||||||
|
utxos.append(utxo)
|
||||||
|
continue
|
||||||
|
spender = getattr(utxo, "spent_txid", None)
|
||||||
|
if spender and Util._is_ignorable_local_spender(
|
||||||
|
wallet, spender, history_label, will_locktime
|
||||||
|
):
|
||||||
|
utxos.append(utxo)
|
||||||
|
return utxos
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_ignorable_local_spender(wallet, spender, history_label, will_locktime):
|
||||||
|
"""True when the local ``spender`` tx is a later BAL history will tx.
|
||||||
|
|
||||||
|
See ``get_available_utxos`` for the exact conditions. Defensive: any
|
||||||
|
lookup failure makes this return False, so a spender is never ignored
|
||||||
|
on uncertain data.
|
||||||
|
"""
|
||||||
|
adb = wallet.adb
|
||||||
|
try:
|
||||||
|
height = int(adb.get_tx_height(spender).height())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
label = wallet.get_label_for_txid(spender)
|
||||||
|
except Exception:
|
||||||
|
label = None
|
||||||
|
if not label or not Util._label_matches_history(label, history_label):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
stored = adb.db.get_transaction(spender)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if stored is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(stored.locktime) > int(will_locktime)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label_matches_history(label, history_label):
|
||||||
|
"""True when ``label`` is the ``history_label`` template with the
|
||||||
|
"{willexecutor}" token substituted by some (possibly empty) executor URL.
|
||||||
|
"""
|
||||||
|
token = "{willexecutor}"
|
||||||
|
if token in history_label:
|
||||||
|
prefix, suffix = history_label.split(token, 1)
|
||||||
|
return (
|
||||||
|
label.startswith(prefix)
|
||||||
|
and label.endswith(suffix)
|
||||||
|
and len(label) >= len(prefix) + len(suffix)
|
||||||
|
)
|
||||||
|
return label == history_label
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def cmp_output(outputa, outputb):
|
def cmp_output(outputa, outputb):
|
||||||
"""Two outputs are equal when both address and value match."""
|
"""Two outputs are equal when both address and value match."""
|
||||||
|
|||||||
630
bal/core/will.py
630
bal/core/will.py
@@ -26,10 +26,9 @@ The status flags themselves (the source of truth) stay here; only the mapping
|
|||||||
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
"status -> colour" now lives in the GUI layer. No behaviour changed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from electrum.i18n import _
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
from electrum.logging import Logger, get_logger
|
from electrum.logging import Logger, get_logger
|
||||||
from electrum.transaction import (
|
from electrum.transaction import (
|
||||||
PartialTransaction,
|
PartialTransaction,
|
||||||
@@ -40,10 +39,13 @@ from electrum.transaction import (
|
|||||||
tx_from_any,
|
tx_from_any,
|
||||||
)
|
)
|
||||||
from electrum.util import (
|
from electrum.util import (
|
||||||
|
UnrelatedTransactionException,
|
||||||
bfh,
|
bfh,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .util import Util
|
from ..i18n import N_, _
|
||||||
|
from .heirs import WillExecutorFeeTooHighException
|
||||||
|
from .util import Util, copy_structure
|
||||||
from .willexecutors import Willexecutors
|
from .willexecutors import Willexecutors
|
||||||
|
|
||||||
MIN_LOCKTIME = 1
|
MIN_LOCKTIME = 1
|
||||||
@@ -72,11 +74,6 @@ class Will:
|
|||||||
if not will[child[0]].father:
|
if not will[child[0]].father:
|
||||||
will[child[0]].father = willid
|
will[child[0]].father = willid
|
||||||
|
|
||||||
# return a list of will sorted by locktime
|
|
||||||
@staticmethod
|
|
||||||
def get_sorted_will(will):
|
|
||||||
return sorted(will.items(), key=lambda x: x[1]["tx"].locktime)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def only_valid(will):
|
def only_valid(will):
|
||||||
for k, v in will.items():
|
for k, v in will.items():
|
||||||
@@ -105,15 +102,6 @@ class Will:
|
|||||||
and not w.get_status("CHECKED")
|
and not w.get_status("CHECKED")
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def search_equal_tx(will, tx, wid):
|
|
||||||
for w in will:
|
|
||||||
if w != wid and not tx.to_json() != will[w]["tx"].to_json():
|
|
||||||
if will[w]["tx"].txid() != tx.txid():
|
|
||||||
if Util.cmp_txs(will[w]["tx"], tx):
|
|
||||||
return will[w]["tx"]
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_tx_from_any(x):
|
def get_tx_from_any(x):
|
||||||
try:
|
try:
|
||||||
@@ -155,7 +143,7 @@ class Will:
|
|||||||
willitems = {}
|
willitems = {}
|
||||||
for wid in will:
|
for wid in will:
|
||||||
Will.add_info_from_will(will, wid, wallet)
|
Will.add_info_from_will(will, wid, wallet)
|
||||||
willitems[wid] = WillItem(will[wid])
|
willitems[wid] = WillItem(will[wid], wallet=wallet)
|
||||||
will = willitems
|
will = willitems
|
||||||
errors = {}
|
errors = {}
|
||||||
for wid in will:
|
for wid in will:
|
||||||
@@ -177,7 +165,7 @@ class Will:
|
|||||||
outputs = will[wid].tx.outputs()
|
outputs = will[wid].tx.outputs()
|
||||||
ow = will[wid]
|
ow = will[wid]
|
||||||
ow.normalize_locktime(others_input)
|
ow.normalize_locktime(others_input)
|
||||||
will[wid] = WillItem(ow.to_dict())
|
will[wid] = ow.copy()
|
||||||
|
|
||||||
for i in range(0, len(outputs)):
|
for i in range(0, len(outputs)):
|
||||||
Will.change_input(
|
Will.change_input(
|
||||||
@@ -219,13 +207,8 @@ class Will:
|
|||||||
if ow.we["url"] == nw.we["url"]:
|
if ow.we["url"] == nw.we["url"]:
|
||||||
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
if int(ow.we["base_fee"]) > int(nw.we["base_fee"]):
|
||||||
return anticipate
|
return anticipate
|
||||||
else:
|
elif int(ow.tx_fees) != int(nw.tx_fees):
|
||||||
if int(ow.tx_fees) != int(nw.tx_fees):
|
return anticipate
|
||||||
return anticipate
|
|
||||||
else:
|
|
||||||
ow.tx.locktime
|
|
||||||
else:
|
|
||||||
ow.tx.locktime
|
|
||||||
else:
|
else:
|
||||||
if nw.we == ow.we:
|
if nw.we == ow.we:
|
||||||
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
|
if not Util.cmp_heirs_by_values(ow.heirs, nw.heirs, [0, 3]):
|
||||||
@@ -446,19 +429,25 @@ class Will:
|
|||||||
for wid in will:
|
for wid in will:
|
||||||
wtx = will[wid].tx
|
wtx = will[wid].tx
|
||||||
found = False
|
found = False
|
||||||
|
inp = None
|
||||||
for inp in wtx.inputs():
|
for inp in wtx.inputs():
|
||||||
if inp.prevout.txid.hex() in will:
|
if inp.prevout.txid.hex() in will:
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
if not found:
|
if not found and inp is not None:
|
||||||
out[inp.prevout.to_str()] = inp
|
out[inp.prevout.to_str()] = inp
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def invalidate_will(will, wallet, fees_per_byte):
|
def invalidate_will(will, wallet, fees_per_byte, history_label=None,
|
||||||
|
will_locktime=None):
|
||||||
|
_logger.debug("invalidate tx in will module")
|
||||||
will_only_valid = Will.only_valid_list(will)
|
will_only_valid = Will.only_valid_list(will)
|
||||||
inputs = Will.get_all_inputs(will_only_valid)
|
inputs = Will.get_all_inputs(will_only_valid)
|
||||||
utxos = wallet.get_utxos()
|
if history_label is not None and will_locktime is not None:
|
||||||
|
utxos = Util.get_available_utxos(wallet, history_label, will_locktime)
|
||||||
|
else:
|
||||||
|
utxos = wallet.get_utxos()
|
||||||
filtered_inputs = []
|
filtered_inputs = []
|
||||||
prevout_to_spend = []
|
prevout_to_spend = []
|
||||||
current_height = Util.get_current_height(wallet.network)
|
current_height = Util.get_current_height(wallet.network)
|
||||||
@@ -472,11 +461,13 @@ class Will:
|
|||||||
utxo_to_spend = []
|
utxo_to_spend = []
|
||||||
for utxo in utxos:
|
for utxo in utxos:
|
||||||
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
if utxo.is_coinbase_output() and utxo.block_height < current_height+100:
|
||||||
|
_logger.debug("is not mature coinbase output")
|
||||||
continue
|
continue
|
||||||
utxo_str = utxo.prevout.to_str()
|
utxo_str = utxo.prevout.to_str()
|
||||||
if utxo_str in prevout_to_spend:
|
if utxo_str in prevout_to_spend:
|
||||||
balance += inputs[utxo_str][0][2].value_sats()
|
balance += utxo.value_sats()
|
||||||
utxo_to_spend.append(utxo)
|
utxo_to_spend.append(utxo)
|
||||||
|
_logger.debug("utxo to spend: {}".format(utxo_to_spend))
|
||||||
if len(utxo_to_spend) > 0:
|
if len(utxo_to_spend) > 0:
|
||||||
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
change_addresses = wallet.get_change_addresses_for_new_transaction()
|
||||||
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
out = PartialTxOutput.from_address_and_value(change_addresses[0], balance)
|
||||||
@@ -508,9 +499,10 @@ 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
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_rai(all_inputs, all_utxos, will, wallet):
|
def search_rai(all_inputs, all_utxos, will, wallet):
|
||||||
@@ -534,10 +526,26 @@ class Will:
|
|||||||
wi.set_status("INVALIDATED", True)
|
wi.set_status("INVALIDATED", True)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if wallet.db.get_transaction(wi._id):
|
# The funding outpoint is not part of the will tree:
|
||||||
wi.set_status("CONFIRMED", True)
|
# decide from whether a broadcast transaction really
|
||||||
else:
|
# spends it (a wallet-local history copy of the same
|
||||||
|
# will tx must neither turn the item CONFIRMED nor
|
||||||
|
# INVALIDATED - it is just a persistence artifact).
|
||||||
|
stored = None
|
||||||
|
if wallet and getattr(wallet, "db", None):
|
||||||
|
try:
|
||||||
|
stored = wallet.db.get_transaction(wi._id)
|
||||||
|
except Exception:
|
||||||
|
stored = None
|
||||||
|
spender_height = Will._funding_spender_height(wallet, inp)
|
||||||
|
if spender_height is None:
|
||||||
|
if stored:
|
||||||
|
continue
|
||||||
wi.set_status("INVALIDATED", True)
|
wi.set_status("INVALIDATED", True)
|
||||||
|
elif spender_height == 0:
|
||||||
|
wi.set_status("MEMPOOL", True)
|
||||||
|
else:
|
||||||
|
wi.set_status("CONFIRMED", True)
|
||||||
|
|
||||||
for child in wi.search(all_inputs):
|
for child in wi.search(all_inputs):
|
||||||
if child.tx.locktime < wi.tx.locktime:
|
if child.tx.locktime < wi.tx.locktime:
|
||||||
@@ -575,14 +583,22 @@ class Will:
|
|||||||
for inp in w.tx.inputs():
|
for inp in w.tx.inputs():
|
||||||
inp_str = Util.utxo_to_str(inp)
|
inp_str = Util.utxo_to_str(inp)
|
||||||
if inp_str not in utxos_list:
|
if inp_str not in utxos_list:
|
||||||
if wallet:
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
height = Will.check_tx_height(w.tx, wallet)
|
continue
|
||||||
if height < 0:
|
height = Will.check_tx_height(w.tx, wallet)
|
||||||
|
if height < 0:
|
||||||
|
# The will tx itself is not on-chain. A missing
|
||||||
|
# funding UTXO is only a real problem when a
|
||||||
|
# broadcast transaction actually spends it; a
|
||||||
|
# wallet-local (history) copy of the same will tx
|
||||||
|
# marks the funding spent locally and must not
|
||||||
|
# invalidate the will.
|
||||||
|
if Will._funding_really_spent(wallet, inp_str):
|
||||||
Will.set_invalidate(wid, willtree)
|
Will.set_invalidate(wid, willtree)
|
||||||
elif height == 0:
|
elif height == 0:
|
||||||
w.set_status("MEMPOOL", True)
|
w.set_status("MEMPOOL", True)
|
||||||
else:
|
else:
|
||||||
w.set_status("CONFIRMED", True)
|
w.set_status("CONFIRMED", True)
|
||||||
|
|
||||||
# def reflect_to_children(treeitem):
|
# def reflect_to_children(treeitem):
|
||||||
# if not treeitem.get_status("VALID"):
|
# if not treeitem.get_status("VALID"):
|
||||||
@@ -598,7 +614,8 @@ class Will:
|
|||||||
# Will.reflect_to_children(wc)
|
# Will.reflect_to_children(wc)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust):
|
def check_amounts(heirs, willexecutors, all_utxos, timestamp_to_check, dust,
|
||||||
|
max_fee=None):
|
||||||
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
|
fixed_heirs, fixed_amount, perc_heirs, perc_amount, fixed_amount_with_dust = (
|
||||||
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
|
heirs.fixed_percent_lists_amount(timestamp_to_check, dust, reverse=True)
|
||||||
)
|
)
|
||||||
@@ -614,13 +631,92 @@ class Will:
|
|||||||
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
raise PercAmountException(f"Perc amount({perc_amount}) =! 100%")
|
||||||
|
|
||||||
for url, wex in willexecutors.items():
|
for url, wex in willexecutors.items():
|
||||||
if Willexecutors.is_selected(wex):
|
if Willexecutors.is_selected(wex) and Willexecutors.is_valid(wex, max_fee=max_fee, dust=dust):
|
||||||
|
if max_fee is not None and int(wex["base_fee"]) > max_fee:
|
||||||
|
raise WillExecutorFeeTooHighException(wex, max_fee)
|
||||||
temp_balance = wallet_balance - int(wex["base_fee"])
|
temp_balance = wallet_balance - int(wex["base_fee"])
|
||||||
if fixed_amount >= temp_balance:
|
if fixed_amount >= temp_balance:
|
||||||
raise FixedAmountException(
|
raise FixedAmountException(
|
||||||
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
f"Willexecutor{url} excess base fee({wex['base_fee']}), {fixed_amount} >={temp_balance}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _funding_really_spent(wallet, inp_str):
|
||||||
|
"""True when a broadcast transaction really spends the ``txid:n`` outpoint.
|
||||||
|
|
||||||
|
``wallet.adb.get_spender`` discards wallet-local spenders (the stored
|
||||||
|
will tx from the local history) and future transactions, so this is True
|
||||||
|
only when the funding was consumed by a real on-chain/mempool tx.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return wallet.adb.get_spender(inp_str) is not None
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_spender failed for {inp_str}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _funding_spender_height(wallet, inp_str):
|
||||||
|
"""Mined height of the broadcast tx spending ``inp_str``, or None.
|
||||||
|
|
||||||
|
Returns ``None`` when no broadcast transaction spends the outpoint (a
|
||||||
|
wallet-local history spender or a future tx are ignored by
|
||||||
|
``adb.get_spender``). The height is 0 for a mempool spender and positive
|
||||||
|
for a confirmed one.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
spender = wallet.adb.get_spender(inp_str)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_spender failed for {inp_str}: {e}")
|
||||||
|
return None
|
||||||
|
if spender is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(wallet.adb.get_tx_height(spender).height())
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"get_tx_height failed for {spender}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _absorb_history_signatures(will, wallet):
|
||||||
|
"""Merge signatures from the wallet's stored local copy of each will tx.
|
||||||
|
|
||||||
|
An incomplete will transaction saved into the local history (see
|
||||||
|
``save_valid_transactions_to_history``) may later accumulate signatures
|
||||||
|
(e.g. after a manual merge from a more complete copy). The in-memory
|
||||||
|
will item would otherwise miss those signatures on the next check. For
|
||||||
|
every item whose stored wallet copy is the same partial transaction the
|
||||||
|
signatures are merged into the in-memory one and, if it becomes fully
|
||||||
|
signed, the item is marked COMPLETE.
|
||||||
|
|
||||||
|
This method must never raise: history absorption is a convenience on top
|
||||||
|
of the will check, so any failure is logged and ignored.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "db", None):
|
||||||
|
return
|
||||||
|
for wi in will.values():
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
wi.tx is None
|
||||||
|
or not isinstance(wi.tx, PartialTransaction)
|
||||||
|
or wi.tx.is_complete()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
stored = wallet.db.get_transaction(wi._id)
|
||||||
|
if (
|
||||||
|
not isinstance(stored, Transaction)
|
||||||
|
or stored.txid() != wi.tx.txid()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
wi.tx.combine_with_other_psbt(stored)
|
||||||
|
if wi.tx.is_complete():
|
||||||
|
wi.set_status("COMPLETE", True)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"absorb history signatures failed for item {wi._id}: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
def check_will(will, all_utxos, wallet, timestamp_to_check):
|
||||||
"""Validate a will against the current wallet state.
|
"""Validate a will against the current wallet state.
|
||||||
@@ -636,6 +732,7 @@ class Will:
|
|||||||
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
timestamp_to_check: The reference UNIX timestamp (usually "now")
|
||||||
used to decide whether any transaction has expired.
|
used to decide whether any transaction has expired.
|
||||||
"""
|
"""
|
||||||
|
Will._absorb_history_signatures(will, wallet)
|
||||||
Will.add_willtree(will)
|
Will.add_willtree(will)
|
||||||
utxos_list = Will.utxos_strs(all_utxos)
|
utxos_list = Will.utxos_strs(all_utxos)
|
||||||
|
|
||||||
@@ -649,10 +746,251 @@ 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 remove_stale_wallet_history(wallet, history_label):
|
||||||
|
"""Delete wallet-LOCAL will transactions saved under ``history_label``.
|
||||||
|
|
||||||
|
``save_valid_transactions_to_history`` stores the not-yet-signed
|
||||||
|
inheritance txs into the wallet's local history; those local
|
||||||
|
placeholders nominally spend the coins they reference. When the will is
|
||||||
|
REBUILT (prepare/build, auto-rebuild, on-close rebuild, CLI build) the
|
||||||
|
stale placeholders must be removed so the coins become available again
|
||||||
|
to the new build (see ``Util.get_available_utxos``). Only
|
||||||
|
wallet-local/future (non-broadcast) txs whose label matches the history
|
||||||
|
label template are removed; confirmed/broadcast history is never
|
||||||
|
touched. Returns the txids that were removed.
|
||||||
|
"""
|
||||||
|
if not wallet or not getattr(wallet, "adb", None):
|
||||||
|
return []
|
||||||
|
removed = []
|
||||||
|
for txid, label in Will._wallet_labels(wallet):
|
||||||
|
if not label or not Util._label_matches_history(label, history_label):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
height = int(wallet.adb.get_tx_height(txid).height())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if height not in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wallet.adb.remove_transaction(txid)
|
||||||
|
removed.append(txid)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"remove from history failed for {txid}: {e}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
wallet.set_label(txid, None)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"set_label failed for {txid}: {e}")
|
||||||
|
try:
|
||||||
|
wallet.save_db()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"save_db failed after history purge: {e}")
|
||||||
|
return removed
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_creation_time(will):
|
||||||
|
"""Return the inheritance creation timestamp (oldest VALID will item).
|
||||||
|
|
||||||
|
A positive relative Check Alive value counts forward from this date.
|
||||||
|
The per-item ``time`` is stamped when the will is built and persisted
|
||||||
|
in the wallet DB, so the anchor does not drift with the clock. Only
|
||||||
|
VALID items are considered; imported items without a ``time`` are
|
||||||
|
ignored. Returns ``None`` when no VALID item carries a timestamp.
|
||||||
|
"""
|
||||||
|
return min(
|
||||||
|
(
|
||||||
|
v.time
|
||||||
|
for v in will.values()
|
||||||
|
if v.get_status('VALID') and v.time is not None
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -777,7 +1115,7 @@ class Will:
|
|||||||
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
timestamp_to_check: Reference UNIX timestamp (usually "now").
|
||||||
"""
|
"""
|
||||||
_logger.info("check if some transaction is expired")
|
_logger.info("check if some transaction is expired")
|
||||||
for prevout_str, wid in all_inputs_min_locktime.items():
|
for _inputs, wid in all_inputs_min_locktime.items():
|
||||||
for w in wid:
|
for w in wid:
|
||||||
if w[1].get_status("VALID"):
|
if w[1].get_status("VALID"):
|
||||||
locktime = int(wid[0][1].tx.locktime)
|
locktime = int(wid[0][1].tx.locktime)
|
||||||
@@ -854,8 +1192,6 @@ class Will:
|
|||||||
if heir := heirs.get(wheir, None):
|
if heir := heirs.get(wheir, None):
|
||||||
|
|
||||||
if heir[0] == their[0] and heir[1] == their[1]:
|
if heir[0] == their[0] and heir[1] == their[1]:
|
||||||
# The requested (possibly new) locktime for this heir.
|
|
||||||
new_locktime = Util.parse_locktime_string(heir[2])
|
|
||||||
# IMPORTANT: compare against the locktime that is
|
# IMPORTANT: compare against the locktime that is
|
||||||
# actually frozen inside the already-signed Bitcoin
|
# actually frozen inside the already-signed Bitcoin
|
||||||
# transaction (w.tx.locktime), NOT against their[2].
|
# transaction (w.tx.locktime), NOT against their[2].
|
||||||
@@ -866,6 +1202,16 @@ class Will:
|
|||||||
# undetected. w.tx.locktime is immutable once signed
|
# undetected. w.tx.locktime is immutable once signed
|
||||||
# and is exactly what the will-executors hold.
|
# and is exactly what the will-executors hold.
|
||||||
tx_locktime = int(w.tx.locktime)
|
tx_locktime = int(w.tx.locktime)
|
||||||
|
# The requested (possibly new) locktime for this heir.
|
||||||
|
# A RELATIVE recipe ("1y"/"30d") is resolved against
|
||||||
|
# the moment the signed tx was built, NOT against now:
|
||||||
|
# re-parsing it from "now" drifts it one day per day
|
||||||
|
# away from the frozen tx locktime, so an UNCHANGED
|
||||||
|
# will would be read as a POSTPONE and the plugin
|
||||||
|
# would ask to invalidate it every day.
|
||||||
|
new_locktime = Util.resolve_locktime_against_tx(
|
||||||
|
heir[2], their[2], tx_locktime
|
||||||
|
)
|
||||||
if new_locktime == tx_locktime:
|
if new_locktime == tx_locktime:
|
||||||
# Unchanged: this heir is still coherent.
|
# Unchanged: this heir is still coherent.
|
||||||
count = heirs_found.get(wheir, 0)
|
count = heirs_found.get(wheir, 0)
|
||||||
@@ -929,15 +1275,15 @@ class Will:
|
|||||||
|
|
||||||
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
if Util.parse_locktime_string(heirs[h][2]) >= check_date:
|
||||||
count_heirs += 1
|
count_heirs += 1
|
||||||
if h not in heirs_found:
|
if h not in heirs_found:
|
||||||
_logger.debug(f"heir: {h} not found")
|
_logger.debug(f"heir: {h} not found")
|
||||||
raise HeirNotFoundException(h)
|
raise HeirNotFoundException(h)
|
||||||
if not count_heirs:
|
if not count_heirs:
|
||||||
raise NoHeirsException("there are not valid heirs")
|
raise NoHeirsException("there are not valid heirs")
|
||||||
if self_willexecutor and no_willexecutor == 0:
|
if self_willexecutor and no_willexecutor == 0:
|
||||||
raise NoWillExecutorNotPresent("Backup tx")
|
raise NoWillExecutorNotPresent("Backup tx")
|
||||||
for url, we in willexecutors.items():
|
for url, we in willexecutors.items():
|
||||||
if Willexecutors.is_selected(we):
|
if Willexecutors.is_selected(we) and Willexecutors.is_valid(we):
|
||||||
if url not in willexecutors_found:
|
if url not in willexecutors_found:
|
||||||
_logger.debug(f"will-executor: {url} not fount")
|
_logger.debug(f"will-executor: {url} not fount")
|
||||||
raise WillExecutorNotPresent(url)
|
raise WillExecutorNotPresent(url)
|
||||||
@@ -946,6 +1292,34 @@ class Will:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def format_status_history(status: str) -> str:
|
||||||
|
"""Return the saved status history of a will item in the GUI language.
|
||||||
|
|
||||||
|
``WillItem.status`` is a dot-separated history such as
|
||||||
|
``".Signed.Pushed.NOT Valid"``. It is stored in English (see
|
||||||
|
``WillItem.set_status``) and translated only here, for display. A token
|
||||||
|
that is not a known English label is shown unchanged: this keeps working
|
||||||
|
the histories written by older versions, which stored the label already
|
||||||
|
translated (for example ``"Firmato"``).
|
||||||
|
"""
|
||||||
|
labels = {label for label, _flag in WillItem.STATUS_DEFAULT.values()}
|
||||||
|
labels.update(WillItem.LEGACY_STATUS_LABELS)
|
||||||
|
|
||||||
|
def translate(token):
|
||||||
|
# "ERROR!!!" is glued to the end of the history when an item has no
|
||||||
|
# id (see WillItem.__init__): keep it, translate what comes before.
|
||||||
|
suffix = ""
|
||||||
|
if token.endswith("ERROR!!!"):
|
||||||
|
token, suffix = token[: -len("ERROR!!!")], "ERROR!!!"
|
||||||
|
if token in labels:
|
||||||
|
token = _(token)
|
||||||
|
elif token.startswith("NOT ") and token[len("NOT "):] in labels:
|
||||||
|
token = _("NOT {}").format(_(token[len("NOT "):]))
|
||||||
|
return token + suffix
|
||||||
|
|
||||||
|
return ".".join(translate(token) for token in status.split("."))
|
||||||
|
|
||||||
|
|
||||||
class WillItem(Logger):
|
class WillItem(Logger):
|
||||||
# Default status flags for an inheritance transaction.
|
# Default status flags for an inheritance transaction.
|
||||||
# Each entry maps an internal status key to [human-readable label, default
|
# Each entry maps an internal status key to [human-readable label, default
|
||||||
@@ -959,27 +1333,37 @@ class WillItem(Logger):
|
|||||||
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
|
# * "UPDATED" was added: the transaction was spendable AND valid, and a new
|
||||||
# transaction replaces it while keeping the SAME locktime and SAME heirs.
|
# transaction replaces it while keeping the SAME locktime and SAME heirs.
|
||||||
# UPDATED keeps the VALID flag (see set_status).
|
# UPDATED keeps the VALID flag (see set_status).
|
||||||
|
#
|
||||||
|
# The labels are marked with N_() but stay English here: they are written
|
||||||
|
# into the saved status history (see set_status) and translated only when
|
||||||
|
# shown, by format_status_history().
|
||||||
STATUS_DEFAULT = {
|
STATUS_DEFAULT = {
|
||||||
"ANTICIPATED": ["Anticipated", False],
|
"ANTICIPATED": [N_("Anticipated"), False],
|
||||||
"BROADCASTED": ["Broadcasted", False],
|
"BROADCASTED": [N_("Broadcasted"), False],
|
||||||
"CHECKED": ["Checked", False],
|
"CHECKED": [N_("Checked"), False],
|
||||||
"CHECK_FAIL": ["Check Failed", False],
|
"CHECK_FAIL": [N_("Check Failed"), False],
|
||||||
"COMPLETE": ["Signed", False],
|
"COMPLETE": [N_("Signed"), False],
|
||||||
"CONFIRMED": ["Confirmed", False],
|
"CONFIRMED": [N_("Confirmed"), False],
|
||||||
"ERROR": ["Error", False],
|
"ERROR": [N_("Error"), False],
|
||||||
"EXPIRED": ["Expired", False],
|
"EXPIRED": [N_("Expired"), False],
|
||||||
"EXPORTED": ["Exported", False],
|
"EXPORTED": [N_("Exported"), False],
|
||||||
"IMPORTED": ["Imported", False],
|
"IMPORTED": [N_("Imported"), False],
|
||||||
"INVALIDATED": ["Invalidated", False],
|
"INVALIDATED": [N_("Invalidated"), False],
|
||||||
"MEMPOOL": ["Mempool", False],
|
"MEMPOOL": [N_("Mempool"), False],
|
||||||
"PUSH_FAIL": ["Push failed", False],
|
"PUSH_FAIL": [N_("Push failed"), False],
|
||||||
"PUSHED": ["Pushed", False],
|
"PUSHED": [N_("Pushed"), False],
|
||||||
"REPLACED": ["Replaced", False],
|
"PARTIALLY_SIGNED": [N_("Partially Signed"), False],
|
||||||
"RESTORED": ["Restored", False],
|
"REPLACED": [N_("Replaced"), False],
|
||||||
"UPDATED": ["Updated", False],
|
"RESTORED": [N_("Restored"), False],
|
||||||
"VALID": ["Valid", True],
|
"UPDATED": [N_("Updated"), False],
|
||||||
|
"VALID": [N_("Valid"), True],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Labels found in histories saved by older versions but no longer
|
||||||
|
# written: "New" opened every history (e.g. tests/samanta7).
|
||||||
|
# format_status_history() still translates them.
|
||||||
|
LEGACY_STATUS_LABELS = (N_("New"),)
|
||||||
|
|
||||||
def set_status(self, status, value=True):
|
def set_status(self, status, value=True):
|
||||||
"""Set a status flag and apply the related side effects.
|
"""Set a status flag and apply the related side effects.
|
||||||
|
|
||||||
@@ -1013,7 +1397,11 @@ class WillItem(Logger):
|
|||||||
if self.STATUS[status][1] == bool(value):
|
if self.STATUS[status][1] == bool(value):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self.status += "." + (("NOT " if not value else "") + _(self.STATUS[status][0]))
|
# The history is saved in the wallet, in exported files and in QR
|
||||||
|
# transfers, so it is always written in English: the saved data must
|
||||||
|
# not depend on the GUI language (older versions wrote the translated
|
||||||
|
# label). format_status_history() translates it when it is shown.
|
||||||
|
self.status += "." + (("NOT " if not value else "") + self.STATUS[status][0])
|
||||||
self.STATUS[status][1] = bool(value)
|
self.STATUS[status][1] = bool(value)
|
||||||
if value:
|
if value:
|
||||||
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
|
# NOTE: ANTICIPATED and UPDATED are intentionally NOT in this list,
|
||||||
@@ -1032,51 +1420,85 @@ 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):
|
||||||
return self.STATUS[status][1]
|
return self.STATUS[status][1]
|
||||||
|
|
||||||
def __init__(self, w, _id=None, wallet=None):
|
def __init__(self, w, _id=None, wallet=None):
|
||||||
if isinstance(
|
if isinstance(w, WillItem):
|
||||||
w,
|
# Copy a WillItem WITHOUT deepcopy. Serialize it to its plain-dict
|
||||||
WillItem,
|
# form and deserialize from there: the tx is re-parsed into a fresh
|
||||||
):
|
# object, STATUS is rebuilt from the clones below and heirs /
|
||||||
self.__dict__ = w.__dict__.copy()
|
# will-executors are cloned recursively, so the copy shares no
|
||||||
else:
|
# mutable state with the source. See also copy().
|
||||||
self.tx = Will.get_tx_from_any(w["tx"])
|
data = w.to_dict()
|
||||||
self.heirs = w.get("heirs", None)
|
data["heirs"] = copy_structure(w.heirs) if w.heirs is not None else None
|
||||||
self.we = w.get("willexecutor", None)
|
data["willexecutor"] = (
|
||||||
self.status = w.get("status", None)
|
copy_structure(w.we) if w.we is not None else None
|
||||||
self.description = w.get("description", None)
|
)
|
||||||
self.time = w.get("time", None)
|
|
||||||
self.change = w.get("change", None)
|
|
||||||
self.tx_fees = w.get("baltx_fees", 0)
|
|
||||||
self.father = w.get("Father", None)
|
|
||||||
self.children = w.get("Children", None)
|
|
||||||
self.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
|
||||||
for s in self.STATUS:
|
|
||||||
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
|
||||||
# Backward-compatibility migration (A2): the "PENDING" status was
|
|
||||||
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
|
||||||
# store the flag under the legacy "PENDING" key, so if that key is
|
|
||||||
# present and set, carry it over to "MEMPOOL". This way no state is
|
|
||||||
# lost when loading an older will. The new key always wins if both
|
|
||||||
# happen to be present.
|
|
||||||
if "MEMPOOL" not in w and w.get("PENDING"):
|
|
||||||
self.STATUS["MEMPOOL"][1] = True
|
|
||||||
if not _id:
|
if not _id:
|
||||||
self._id = self.tx.txid()
|
_id = w._id
|
||||||
else:
|
w = data
|
||||||
self._id = _id
|
self.tx = Will.get_tx_from_any(w["tx"])
|
||||||
|
self.heirs = w.get("heirs", None)
|
||||||
|
self.we = w.get("willexecutor", None)
|
||||||
|
self.status = w.get("status") or ""
|
||||||
|
self.description = w.get("description", None)
|
||||||
|
self.time = w.get("time", None)
|
||||||
|
self.change = w.get("change", None)
|
||||||
|
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.children = w.get("Children", None)
|
||||||
|
self.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
|
for s in self.STATUS:
|
||||||
|
self.STATUS[s][1] = w.get(s, WillItem.STATUS_DEFAULT[s][1])
|
||||||
|
# Backward-compatibility migration (A2): the "PENDING" status was
|
||||||
|
# renamed to "MEMPOOL". Wills saved by older versions of the plugin
|
||||||
|
# store the flag under the legacy "PENDING" key, so if that key is
|
||||||
|
# present and set, carry it over to "MEMPOOL". This way no state is
|
||||||
|
# lost when loading an older will. The new key always wins if both
|
||||||
|
# happen to be present.
|
||||||
|
if "MEMPOOL" not in w and w.get("PENDING"):
|
||||||
|
self.STATUS["MEMPOOL"][1] = True
|
||||||
|
if not _id:
|
||||||
|
self._id = self.tx.txid()
|
||||||
|
else:
|
||||||
|
self._id = _id
|
||||||
|
|
||||||
if not self._id:
|
if not self._id:
|
||||||
self.status += "ERROR!!!"
|
self.status += "ERROR!!!"
|
||||||
self.valid = False
|
self.valid = False
|
||||||
|
|
||||||
if wallet:
|
if wallet:
|
||||||
self.tx.add_info_from_wallet(wallet)
|
self.tx.add_info_from_wallet(wallet)
|
||||||
|
|
||||||
|
def copy(self, wallet=None):
|
||||||
|
"""Return an independent copy of this WillItem (no deepcopy).
|
||||||
|
|
||||||
|
The copy is produced by serializing this item and deserializing it:
|
||||||
|
the transaction is re-parsed, the STATUS table is rebuilt and
|
||||||
|
heirs / will-executors are cloned recursively, so the result shares no
|
||||||
|
mutable state with ``self``. Pass a ``wallet`` when the copy's tx
|
||||||
|
needs its address/value information restored
|
||||||
|
(``tx.add_info_from_wallet``).
|
||||||
|
"""
|
||||||
|
return WillItem(self, _id=self._id, wallet=wallet)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def copy_status_table(status_table):
|
||||||
|
"""Clone a STATUS table (``{flag: [label, bool]}``) without deepcopy.
|
||||||
|
|
||||||
|
Both the outer dict and every inner ``[label, bool]`` list are new
|
||||||
|
objects, so mutating the returned table never affects the source.
|
||||||
|
"""
|
||||||
|
return {k: [label, value] for k, (label, value) in status_table.items()}
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
out = {
|
out = {
|
||||||
"_id": self._id,
|
"_id": self._id,
|
||||||
@@ -1088,6 +1510,10 @@ 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,
|
||||||
|
"Father": self.father,
|
||||||
|
"Children": self.children,
|
||||||
}
|
}
|
||||||
for key in self.STATUS:
|
for key in self.STATUS:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ interaction is handled by the Qt layer.
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import ClientResponse
|
from aiohttp import ClientResponse
|
||||||
from electrum.i18n import _
|
from electrum import bitcoin, constants
|
||||||
|
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import Network
|
from electrum.network import Network
|
||||||
|
|
||||||
from .plugin_base import BalPlugin
|
from ..i18n import _
|
||||||
|
from .plugin_base import BalPlugin, get_version
|
||||||
|
|
||||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||||
# list download). These fail fast (no retries) so a dead server does not
|
# list download). These fail fast (no retries) so a dead server does not
|
||||||
@@ -109,8 +112,6 @@ def is_tor_active():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
chainname = BalPlugin.chainname
|
|
||||||
|
|
||||||
|
|
||||||
class Willexecutors:
|
class Willexecutors:
|
||||||
|
|
||||||
@@ -143,19 +144,19 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def save(bal_plugin, willexecutors):
|
def save(bal_plugin, willexecutors):
|
||||||
_logger.debug(f"save {willexecutors},{chainname}")
|
_logger.debug(f"save {willexecutors},{BalPlugin.chainname}")
|
||||||
aw = bal_plugin.WILLEXECUTORS.get()
|
aw = bal_plugin.WILLEXECUTORS.get()
|
||||||
aw[chainname] = willexecutors
|
aw[BalPlugin.chainname] = willexecutors
|
||||||
bal_plugin.WILLEXECUTORS.set(aw)
|
bal_plugin.WILLEXECUTORS.set(aw)
|
||||||
_logger.debug(f"saved: {aw}")
|
_logger.debug(f"saved: {aw}")
|
||||||
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
# bal_plugin.WILLEXECUTORS.set(willexecutors)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutors(
|
def get_willexecutors(
|
||||||
bal_plugin, update=False, bal_window=False, force=False, task=True
|
bal_plugin, update=False, bal_window: Any = None, force=False, task=True
|
||||||
):
|
):
|
||||||
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
willexecutors = bal_plugin.WILLEXECUTORS.get()
|
||||||
willexecutors = willexecutors.get(chainname, {})
|
willexecutors = willexecutors.get(BalPlugin.chainname, {})
|
||||||
to_del = []
|
to_del = []
|
||||||
for w in willexecutors:
|
for w in willexecutors:
|
||||||
if not isinstance(willexecutors[w], dict):
|
if not isinstance(willexecutors[w], dict):
|
||||||
@@ -169,7 +170,7 @@ class Willexecutors:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
del willexecutors[w]
|
del willexecutors[w]
|
||||||
bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
|
bal = bal_plugin.WILLEXECUTORS.default.get(BalPlugin.chainname, {})
|
||||||
for bal_url, bal_executor in bal.items():
|
for bal_url, bal_executor in bal.items():
|
||||||
if bal_url not in willexecutors:
|
if bal_url not in willexecutors:
|
||||||
_logger.debug(f"force add {bal_url} willexecutor")
|
_logger.debug(f"force add {bal_url} willexecutor")
|
||||||
@@ -214,6 +215,20 @@ class Willexecutors:
|
|||||||
willexecutor["selected"] = False
|
willexecutor["selected"] = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid(willexecutor, max_fee=None, dust=None):
|
||||||
|
if not willexecutor:
|
||||||
|
return False
|
||||||
|
address = willexecutor.get("address", "")
|
||||||
|
if not address or not bitcoin.is_address(address, net=constants.net):
|
||||||
|
return False
|
||||||
|
base_fee = int(willexecutor.get("base_fee", 0))
|
||||||
|
if dust is not None and base_fee < dust:
|
||||||
|
return False
|
||||||
|
if max_fee is not None and base_fee > max_fee:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_willexecutor_transactions(will, force=False):
|
def get_willexecutor_transactions(will, force=False):
|
||||||
willexecutors = {}
|
willexecutors = {}
|
||||||
@@ -272,55 +287,48 @@ class Willexecutors:
|
|||||||
raise Exception("You are offline.")
|
raise Exception("You are offline.")
|
||||||
_logger.debug(f"<-- {method} {url} {data}")
|
_logger.debug(f"<-- {method} {url} {data}")
|
||||||
headers = {}
|
headers = {}
|
||||||
headers["user-agent"] = f"BalPlugin v:{BalPlugin.__version__}"
|
headers["user-agent"] = f"BalPlugin v:{get_version()}"
|
||||||
headers["Content-Type"] = "text/plain"
|
headers["Content-Type"] = "text/plain"
|
||||||
if not handle_response:
|
if not handle_response:
|
||||||
handle_response = Willexecutors.handle_response
|
handle_response = Willexecutors.handle_response
|
||||||
try:
|
attempts = max_retries + 1
|
||||||
if method == "get":
|
for attempt in range(attempts):
|
||||||
response = Network.send_http_on_proxy(
|
try:
|
||||||
method,
|
if method == "get":
|
||||||
url,
|
response = Network.send_http_on_proxy(
|
||||||
params=data,
|
method,
|
||||||
headers=headers,
|
url,
|
||||||
on_finish=handle_response,
|
params=data,
|
||||||
timeout=timeout,
|
headers=headers,
|
||||||
)
|
on_finish=handle_response,
|
||||||
elif method == "post":
|
timeout=timeout,
|
||||||
response = Network.send_http_on_proxy(
|
)
|
||||||
method,
|
elif method == "post":
|
||||||
url,
|
response = Network.send_http_on_proxy(
|
||||||
body=data,
|
method,
|
||||||
headers=headers,
|
url,
|
||||||
on_finish=handle_response,
|
body=data,
|
||||||
timeout=timeout,
|
headers=headers,
|
||||||
)
|
on_finish=handle_response,
|
||||||
else:
|
timeout=timeout,
|
||||||
raise Exception(f"unexpected {method=!r}")
|
)
|
||||||
except TimeoutError:
|
else:
|
||||||
if count_reply < max_retries:
|
raise Exception(f"unexpected {method=!r}")
|
||||||
_logger.debug(
|
_logger.debug(f"--> {response}")
|
||||||
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
return response
|
||||||
)
|
except TimeoutError:
|
||||||
if retry_sleep:
|
if attempt < max_retries:
|
||||||
time.sleep(retry_sleep)
|
_logger.debug(
|
||||||
return Willexecutors.send_request(
|
f"timeout({attempt}) error: "
|
||||||
method,
|
f"retry in {retry_sleep} sec..."
|
||||||
url,
|
)
|
||||||
data,
|
if retry_sleep:
|
||||||
timeout=timeout,
|
time.sleep(retry_sleep)
|
||||||
handle_response=handle_response,
|
else:
|
||||||
count_reply=count_reply + 1,
|
_logger.debug(f"Too many timeouts: {attempt}")
|
||||||
max_retries=max_retries,
|
except Exception as e:
|
||||||
retry_sleep=retry_sleep,
|
raise e
|
||||||
)
|
return None
|
||||||
else:
|
|
||||||
_logger.debug(f"Too many timeouts: {count_reply}")
|
|
||||||
except Exception as e:
|
|
||||||
raise e
|
|
||||||
else:
|
|
||||||
_logger.debug(f"--> {response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_we_url_from_response(resp):
|
def get_we_url_from_response(resp):
|
||||||
@@ -331,15 +339,11 @@ class Willexecutors:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_response(resp: ClientResponse):
|
async def handle_response(resp: ClientResponse):
|
||||||
|
resp.raise_for_status()
|
||||||
r = await resp.text()
|
r = await resp.text()
|
||||||
try:
|
try:
|
||||||
|
|
||||||
r = json.loads(r)
|
r = json.loads(r)
|
||||||
# url = Willexecutors.get_we_url_from_response(resp)
|
except json.JSONDecodeError:
|
||||||
# r["url"]= url
|
|
||||||
# r["status"]=resp.status
|
|
||||||
except Exception as e:
|
|
||||||
_logger.debug(f"error handling response:{e}")
|
|
||||||
pass
|
pass
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@@ -362,23 +366,25 @@ class Willexecutors:
|
|||||||
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||||
if w := Willexecutors.send_request(
|
if w := Willexecutors.send_request(
|
||||||
"post",
|
"post",
|
||||||
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
willexecutor["url"] + "/" + BalPlugin.chainname + "/pushtxs",
|
||||||
data=willexecutor["txs"].encode("ascii"),
|
data=willexecutor["txs"].encode("ascii"),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
max_retries=max_retries,
|
max_retries=max_retries,
|
||||||
retry_sleep=retry_sleep,
|
retry_sleep=retry_sleep,
|
||||||
):
|
):
|
||||||
willexecutor["broadcast_status"] = _("Success")
|
|
||||||
_logger.debug(f"pushed: {w}")
|
_logger.debug(f"pushed: {w}")
|
||||||
if w != "thx":
|
if w != "thx":
|
||||||
_logger.debug(f"error: {w}")
|
_logger.debug(f"error: {w}")
|
||||||
raise Exception(w)
|
raise Exception(w)
|
||||||
|
willexecutor["broadcast_status"] = _("Success")
|
||||||
else:
|
else:
|
||||||
raise Exception("empty reply from:{willexecutor['url']}")
|
raise Exception(
|
||||||
|
f"empty reply from:{willexecutor['url']}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug(f"error:{e}")
|
_logger.debug(f"error:{e}")
|
||||||
if str(e) == "already present":
|
if str(e) == "already present":
|
||||||
raise Willexecutors.AlreadyPresentException()
|
raise Willexecutors.AlreadyPresentException() from None
|
||||||
out = False
|
out = False
|
||||||
willexecutor["broadcast_status"] = _("Failed")
|
willexecutor["broadcast_status"] = _("Failed")
|
||||||
|
|
||||||
@@ -400,15 +406,38 @@ class Willexecutors:
|
|||||||
# single short timeout instead of retrying 10x with sleeps, which
|
# single short timeout instead of retrying 10x with sleeps, which
|
||||||
# used to freeze the UI for minutes per unreachable server.
|
# used to freeze the UI for minutes per unreachable server.
|
||||||
w = Willexecutors.send_request(
|
w = Willexecutors.send_request(
|
||||||
"get", url + "/" + chainname + "/info",
|
"get", url + "/" + BalPlugin.chainname + "/info",
|
||||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
)
|
)
|
||||||
if isinstance(w, dict):
|
if isinstance(w, dict):
|
||||||
willexecutor["url"] = url
|
address = w.get("address")
|
||||||
willexecutor["status"] = 200
|
if not isinstance(address, str) or not bitcoin.is_address(
|
||||||
willexecutor["base_fee"] = w["base_fee"]
|
address, net=constants.net
|
||||||
willexecutor["address"] = w["address"]
|
):
|
||||||
willexecutor["info"] = w["info"]
|
_logger.warning(
|
||||||
|
f"invalid address from {url}: {address!r}"
|
||||||
|
)
|
||||||
|
willexecutor["status"] = "KO"
|
||||||
|
else:
|
||||||
|
base_fee = w.get("base_fee")
|
||||||
|
try:
|
||||||
|
base_fee = int(base_fee or 0)
|
||||||
|
if base_fee < 0:
|
||||||
|
raise ValueError("negative fee")
|
||||||
|
if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
|
||||||
|
raise ValueError("fee exceeds total coin supply")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
_logger.warning(
|
||||||
|
f"invalid base_fee from {url}: "
|
||||||
|
f"{w.get('base_fee')!r} ({e})"
|
||||||
|
)
|
||||||
|
willexecutor["status"] = "KO"
|
||||||
|
else:
|
||||||
|
willexecutor["url"] = url
|
||||||
|
willexecutor["status"] = 200
|
||||||
|
willexecutor["base_fee"] = base_fee
|
||||||
|
willexecutor["address"] = address
|
||||||
|
willexecutor["info"] = w.get("info", "")
|
||||||
else:
|
else:
|
||||||
# No dict reply (timeout / empty) -> mark as unreachable.
|
# No dict reply (timeout / empty) -> mark as unreachable.
|
||||||
willexecutor["status"] = "KO"
|
willexecutor["status"] = "KO"
|
||||||
@@ -451,8 +480,7 @@ class Willexecutors:
|
|||||||
Returns:
|
Returns:
|
||||||
The same ``willexecutors`` mapping, updated in place.
|
The same ``willexecutors`` mapping, updated in place.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from concurrent.futures import FIRST_COMPLETED
|
|
||||||
|
|
||||||
items = list(willexecutors.items())
|
items = list(willexecutors.items())
|
||||||
if not items:
|
if not items:
|
||||||
@@ -532,8 +560,7 @@ class Willexecutors:
|
|||||||
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
||||||
answered in time (timed-out servers are reported via ``on_timeout``).
|
answered in time (timed-out servers are reported via ``on_timeout``).
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from concurrent.futures import FIRST_COMPLETED
|
|
||||||
|
|
||||||
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
||||||
results = {}
|
results = {}
|
||||||
@@ -650,8 +677,7 @@ class Willexecutors:
|
|||||||
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
||||||
that answered in time.
|
that answered in time.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from concurrent.futures import FIRST_COMPLETED
|
|
||||||
|
|
||||||
targets = [(wid, url) for wid, url in items if url]
|
targets = [(wid, url) for wid, url in items if url]
|
||||||
results = {}
|
results = {}
|
||||||
@@ -742,7 +768,14 @@ class Willexecutors:
|
|||||||
else:
|
else:
|
||||||
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
|
||||||
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
|
||||||
willexecutor["address"]=old_willexecutor.get("address",willexecutor.get("address",""))
|
address = old_willexecutor.get("address", willexecutor.get("address", ""))
|
||||||
|
if address and not bitcoin.is_address(address, net=constants.net):
|
||||||
|
_logger.warning(
|
||||||
|
f"invalid address {address!r} for executor {url}, "
|
||||||
|
f"falling back to empty"
|
||||||
|
)
|
||||||
|
address = ""
|
||||||
|
willexecutor["address"] = address
|
||||||
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))
|
||||||
|
|
||||||
|
|
||||||
@@ -753,16 +786,25 @@ class Willexecutors:
|
|||||||
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
|
||||||
willexecutors = Willexecutors.send_request(
|
willexecutors = Willexecutors.send_request(
|
||||||
"get",
|
"get",
|
||||||
f"{welist_server}data/{chainname}?page=0&limit=100",
|
f"{welist_server}data/{BalPlugin.chainname}?page=0&limit=100",
|
||||||
)
|
)
|
||||||
# del willexecutors["status"]
|
if not isinstance(willexecutors, dict):
|
||||||
|
_logger.warning(
|
||||||
|
f"unexpected download_list response type: "
|
||||||
|
f"{type(willexecutors).__name__}"
|
||||||
|
)
|
||||||
|
return {}
|
||||||
for w in willexecutors:
|
for w in willexecutors:
|
||||||
if w not in ("status", "url"):
|
if w not in ("status", "url"):
|
||||||
|
if not isinstance(willexecutors.get(w), dict):
|
||||||
|
_logger.warning(
|
||||||
|
f"malformed entry {w!r} in executor list, "
|
||||||
|
f"type={type(willexecutors.get(w)).__name__}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
Willexecutors.initialize_willexecutor(
|
Willexecutors.initialize_willexecutor(
|
||||||
willexecutors[w], w, None, old_willexecutors.get(w,None)
|
willexecutors[w], w, None, old_willexecutors.get(w,None)
|
||||||
)
|
)
|
||||||
# bal_plugin.WILLEXECUTORS.set(l)
|
|
||||||
# bal_plugin.config.set_key(bal_plugin.WILLEXECUTORS,l,save=True)
|
|
||||||
return willexecutors
|
return willexecutors
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -794,6 +836,12 @@ class Willexecutors:
|
|||||||
"post", url + "/searchtx", data=txid.encode("ascii"),
|
"post", url + "/searchtx", data=txid.encode("ascii"),
|
||||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||||
)
|
)
|
||||||
|
if not isinstance(w, dict):
|
||||||
|
_logger.warning(
|
||||||
|
f"unexpected check_transaction response type "
|
||||||
|
f"from {url}: {type(w).__name__}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
return w
|
return w
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"error contacting {url} for checking txs {e}")
|
_logger.error(f"error contacting {url} for checking txs {e}")
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
|
|||||||
When a will is built, the plugin can create a calendar event reminding the user
|
When a will is built, the plugin can create a calendar event reminding the user
|
||||||
to "check in" before the locktime expires. This module turns the event data
|
to "check in" before the locktime expires. This module turns the event data
|
||||||
into an RFC-5545 .ics file and opens it with the OS default application.
|
into an RFC-5545 .ics file and opens it with the OS default application.
|
||||||
|
|
||||||
|
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
|
||||||
|
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
|
||||||
|
the Qt button and the OS/subprocess glue.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .common import *
|
import os
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
import subprocess
|
||||||
|
|
||||||
|
from electrum.gui.qt.util import getSaveFileName
|
||||||
from PyQt6.QtGui import QAction
|
from PyQt6.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QToolButton
|
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
|
||||||
|
|
||||||
|
from ...core.reminders import write_temp_ics
|
||||||
|
from .common import _, _logger
|
||||||
|
|
||||||
|
|
||||||
class BalCalendarButton(QToolButton):
|
class BalCalendarButton(QToolButton):
|
||||||
@@ -56,7 +65,7 @@ class BalCalendarButton(QToolButton):
|
|||||||
try:
|
try:
|
||||||
content = self._ics_provider()
|
content = self._ics_provider()
|
||||||
if content:
|
if content:
|
||||||
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
|
self._calendar_temp_path = write_temp_ics(content)
|
||||||
else:
|
else:
|
||||||
self._calendar_temp_path = None
|
self._calendar_temp_path = None
|
||||||
self._bal_window.show_warning(
|
self._bal_window.show_warning(
|
||||||
@@ -79,7 +88,8 @@ class BalCalendarButton(QToolButton):
|
|||||||
path = self._ensure_ics()
|
path = self._ensure_ics()
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
import shlex, subprocess
|
import shlex
|
||||||
|
import subprocess
|
||||||
if self._bal_window.bal_plugin.is_basic_mode():
|
if self._bal_window.bal_plugin.is_basic_mode():
|
||||||
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
app = self._bal_window.bal_plugin.CALENDAR_APP.default
|
||||||
else:
|
else:
|
||||||
@@ -147,13 +157,6 @@ class BalCalendarButton(QToolButton):
|
|||||||
|
|
||||||
|
|
||||||
class BalCalendar:
|
class BalCalendar:
|
||||||
@staticmethod
|
|
||||||
def write_temp_ics(content):
|
|
||||||
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
|
|
||||||
with os.fdopen(fd, "wb") as f:
|
|
||||||
f.write(content.encode("utf-8"))
|
|
||||||
return path
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def open_with_default_app(calendar_app, path):
|
def open_with_default_app(calendar_app, path):
|
||||||
_logger.debug("opening calendar app")
|
_logger.debug("opening calendar app")
|
||||||
@@ -182,52 +185,3 @@ class BalCalendar:
|
|||||||
if os.path.isdir(desktop):
|
if os.path.isdir(desktop):
|
||||||
return desktop
|
return desktop
|
||||||
return home
|
return home
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def format_time(time):
|
|
||||||
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
||||||
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ical_escape(text: str) -> str:
|
|
||||||
# escape per RFC5545: backslash, ; , newlines
|
|
||||||
text = text.encode("utf-8")
|
|
||||||
text = (
|
|
||||||
text.replace(b"\\", b"\\\\")
|
|
||||||
.replace(b";", b"\\;")
|
|
||||||
.replace(b",", b"\\,")
|
|
||||||
)
|
|
||||||
out =""
|
|
||||||
temp=text.split(b"\r\n")
|
|
||||||
for s in temp:
|
|
||||||
encoded= s
|
|
||||||
cut =0
|
|
||||||
while len(encoded) >75:
|
|
||||||
cut+=5
|
|
||||||
encoded=f"{s[:len(s)-cut]}"
|
|
||||||
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
|
|
||||||
cut += 1
|
|
||||||
encoded=f"{s[:len(s)-cut]}"
|
|
||||||
encoded=f"{encoded}...\r\n".encode("utf-8")
|
|
||||||
if cut>0:
|
|
||||||
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
|
|
||||||
else:
|
|
||||||
out+=str(f"{s.decode()}\r\n")
|
|
||||||
|
|
||||||
return out[:-2]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
|
||||||
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
|
|
||||||
encoded = line.encode("utf-8")
|
|
||||||
parts = []
|
|
||||||
while len(encoded) > limit:
|
|
||||||
# taglia senza spezzare byte UTF-8
|
|
||||||
cut = limit
|
|
||||||
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
|
|
||||||
cut -= 1
|
|
||||||
parts.append(encoded[:cut].decode("utf-8"))
|
|
||||||
encoded = encoded[cut:]
|
|
||||||
parts.append(encoded.decode("utf-8"))
|
|
||||||
return "\r\n ".join(parts)
|
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ hosts a few GUI helpers that do not deserve a module of their own:
|
|||||||
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
||||||
* :func:`log_error` - format an exception traceback for a dialog.
|
* :func:`log_error` - format an exception traceback for a dialog.
|
||||||
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
||||||
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
|
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import enum
|
import enum
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -27,62 +26,157 @@ from decimal import Decimal
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any, Callable, Mapping, Optional, Union
|
from typing import Any, Callable, Mapping, Optional, Union
|
||||||
|
|
||||||
from electrum.bitcoin import (NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX,
|
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||||
NLOCKTIME_MIN)
|
from electrum.gui.common_qt.util import draw_qr
|
||||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||||
from electrum.gui.qt.my_treeview import MyTreeView
|
from electrum.gui.qt.my_treeview import MyTreeView
|
||||||
from electrum.gui.qt.password_dialog import PasswordDialog
|
from electrum.gui.qt.password_dialog import PasswordDialog
|
||||||
from electrum.gui.qt.transaction_dialog import TxDialog
|
from electrum.gui.qt.transaction_dialog import TxDialog
|
||||||
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
|
from electrum.gui.qt.util import (
|
||||||
EnterButton, HelpButton, MessageBoxMixin,
|
Buttons,
|
||||||
OkButton, TaskThread, WindowModalDialog,
|
CancelButton,
|
||||||
char_width_in_lineedit, getSaveFileName,
|
ColorScheme,
|
||||||
import_meta_gui, read_QIcon_from_bytes,
|
EnterButton,
|
||||||
read_QPixmap_from_bytes, webopen)
|
HelpButton,
|
||||||
from electrum.i18n import _
|
MessageBoxMixin,
|
||||||
|
OkButton,
|
||||||
|
TaskThread,
|
||||||
|
WaitingDialog,
|
||||||
|
WindowModalDialog,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
getOpenFileName,
|
||||||
|
getSaveFileName,
|
||||||
|
import_meta_gui,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
read_QPixmap_from_bytes,
|
||||||
|
webopen,
|
||||||
|
)
|
||||||
from electrum.logging import get_logger
|
from electrum.logging import get_logger
|
||||||
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
|
||||||
from electrum.payment_identifier import PaymentIdentifier
|
from electrum.payment_identifier import PaymentIdentifier
|
||||||
from electrum.plugin import hook
|
from electrum.plugin import hook
|
||||||
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
from electrum.transaction import SerializationError, Transaction, tx_from_any
|
||||||
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
|
from electrum.util import (
|
||||||
decimal_point_to_base_unit_name, read_json_file,
|
DECIMAL_POINT,
|
||||||
write_json_file)
|
FileExportFailed,
|
||||||
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
|
FileImportFailed,
|
||||||
Qt, QTimer, pyqtSignal)
|
UserCancelled,
|
||||||
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
decimal_point_to_base_unit_name,
|
||||||
QStandardItemModel)
|
read_json_file,
|
||||||
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
write_json_file,
|
||||||
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
|
)
|
||||||
QInputDialog, QLabel, QLineEdit, QTextEdit, QMenu,
|
from PyQt6.QtCore import (
|
||||||
QMenuBar, QPushButton, QScrollArea, QSizePolicy,
|
QDateTime,
|
||||||
QSpinBox, QStackedWidget, QStyle, QStyleOptionFrame,
|
QModelIndex,
|
||||||
QVBoxLayout, QWidget, QDialog)
|
QPersistentModelIndex,
|
||||||
|
QSize,
|
||||||
|
Qt,
|
||||||
|
QTimer,
|
||||||
|
pyqtSignal,
|
||||||
|
)
|
||||||
|
from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItemModel
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QAbstractSpinBox,
|
||||||
|
QApplication,
|
||||||
|
QButtonGroup,
|
||||||
|
QCheckBox,
|
||||||
|
QComboBox,
|
||||||
|
QDateTimeEdit,
|
||||||
|
QDialog,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMenu,
|
||||||
|
QMenuBar,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QScrollArea,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QStyle,
|
||||||
|
QStyleOptionFrame,
|
||||||
|
QTextEdit,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...core.heirs import (
|
||||||
|
HEIR_DUST_AMOUNT,
|
||||||
|
HEIR_REAL_AMOUNT,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
|
BalanceTooLowException,
|
||||||
|
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,
|
from ...core.util import Util, copy_structure
|
||||||
HeirAmountIsDustException, Heirs)
|
from ...core.will import (
|
||||||
from ...core.util import Util
|
AmountException,
|
||||||
from ...core.will import (AmountException, HeirChangeException,
|
HeirChangeException,
|
||||||
HeirNotFoundException, NoHeirsException,
|
HeirNotFoundException,
|
||||||
NotCompleteWillException, NoWillExecutorNotPresent,
|
NoHeirsException,
|
||||||
TxFeesChangedException, Will,
|
NotCompleteWillException,
|
||||||
WillexecutorChangeException, WillExecutorNotPresent,
|
NoWillExecutorNotPresent,
|
||||||
WillExpiredException, WillItem, WillPostponedException)
|
TxFeesChangedException,
|
||||||
from ...core.willexecutors import Willexecutors
|
Will,
|
||||||
from ...core.willexecutors import is_onion_url, is_tor_active # noqa: F401
|
WillexecutorChangeException,
|
||||||
|
WillExecutorNotPresent,
|
||||||
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
|
WillPostponedException,
|
||||||
|
format_status_history,
|
||||||
|
)
|
||||||
|
from ...core.willexecutors import ( # noqa: F401
|
||||||
|
Willexecutors,
|
||||||
|
is_onion_url,
|
||||||
|
is_tor_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
# BAL's translator: Electrum's catalog first, then BAL's (see bal/i18n.py).
|
||||||
|
from ...i18n import N_, _
|
||||||
|
|
||||||
# --- Presentation helpers ---
|
# --- Presentation helpers ---
|
||||||
from .theme import server_status_text, server_status_tooltip, status_color
|
from .theme import (
|
||||||
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
server_status_text,
|
||||||
stop_thread, top_level_of)
|
server_status_tooltip,
|
||||||
|
signature_suffix,
|
||||||
|
status_color,
|
||||||
|
)
|
||||||
|
from .window_utils import (
|
||||||
|
bring_to_front,
|
||||||
|
show_modal,
|
||||||
|
show_on_top,
|
||||||
|
stop_thread,
|
||||||
|
top_level_of,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = get_logger(__name__)
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Labels of bal.core.qrtransfer.CHUNK_PRESETS, marked for translation here
|
||||||
|
# because qrtransfer.py must stay free of Electrum imports (the Android reader
|
||||||
|
# ships a copy of it). The combos show them with _(label);
|
||||||
|
# tests/test_i18n.py checks that this list matches CHUNK_PRESETS.
|
||||||
|
QR_PRESET_LABELS = (
|
||||||
|
N_("Small - ~150 bytes/QR (low-res cameras)"),
|
||||||
|
N_("Medium - ~400 bytes/QR"),
|
||||||
|
N_("Large - ~900 bytes/QR"),
|
||||||
|
N_("XL - ~1800 bytes/QR (high-res cameras)"),
|
||||||
|
)
|
||||||
|
|
||||||
class shown_cv:
|
|
||||||
|
class shown_cv: # noqa: N801 (intentionally lowercase: mirrors Qt signal naming)
|
||||||
_type = bool
|
_type = bool
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
@@ -98,21 +192,26 @@ class shown_cv:
|
|||||||
|
|
||||||
|
|
||||||
def add_widget(grid, label, widget, row, help_):
|
def add_widget(grid, label, widget, row, help_):
|
||||||
grid.addWidget(QLabel(_(label)), row, 0)
|
"""Add a ``label | widget | help button`` row to ``grid``.
|
||||||
|
|
||||||
|
``label`` and ``help_`` must already be translated by the caller: a
|
||||||
|
variable passed to _() cannot be extracted into the catalog.
|
||||||
|
"""
|
||||||
|
grid.addWidget(QLabel(label), row, 0)
|
||||||
grid.addWidget(widget, row, 1)
|
grid.addWidget(widget, row, 1)
|
||||||
grid.addWidget(HelpButton(help_), row, 2)
|
grid.addWidget(HelpButton(help_), row, 2)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CheckAliveError(Exception):
|
def translated_headers(headers):
|
||||||
def __init__(self, timestamp_to_check):
|
"""Return a translated copy of a list's ``headers`` class attribute.
|
||||||
self.timestamp_to_check = timestamp_to_check
|
|
||||||
|
|
||||||
def __str__(self):
|
The column headers are class attributes marked with N_(): a class body
|
||||||
return "Check alive expired please update it: {}".format(
|
runs at import time, before the catalog is loaded, so they are
|
||||||
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
|
translated here, each time the headers are (re)built.
|
||||||
)
|
"""
|
||||||
|
return {column: _(text) for column, text in headers.items()}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -154,7 +253,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
|||||||
filter_ = "All files (*)"
|
filter_ = "All files (*)"
|
||||||
filename = getSaveFileName(
|
filename = getSaveFileName(
|
||||||
parent=electrum_window,
|
parent=electrum_window,
|
||||||
title=_("Select file to save your {}".format(title)),
|
title=_("Select file to save your {}").format(title),
|
||||||
filename="BALplugin_{}_{}_{}".format(
|
filename="BALplugin_{}_{}_{}".format(
|
||||||
BalPlugin.chainname, str(electrum_window.wallet), title
|
BalPlugin.chainname, str(electrum_window.wallet), title
|
||||||
),
|
),
|
||||||
@@ -169,7 +268,7 @@ def export_meta_gui(electrum_window, title, exporter):
|
|||||||
electrum_window.show_critical(str(e))
|
electrum_window.show_critical(str(e))
|
||||||
else:
|
else:
|
||||||
electrum_window.show_message(
|
electrum_window.show_message(
|
||||||
_("Your {0} were exported to '{1}'".format(title, str(filename)))
|
_("Your {0} were exported to '{1}'").format(title, str(filename))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,114 @@ construction) for all business actions, so the heavy logic stays in ``window``
|
|||||||
and ``dialogs``.
|
and ``dialogs``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .common import *
|
from typing import TYPE_CHECKING
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
|
||||||
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
|
from PyQt6.QtWidgets import QLineEdit as _QLineEdit
|
||||||
from PyQt6.QtWidgets import QMessageBox
|
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
|
||||||
from PyQt6.QtWidgets import QStyledItemDelegate, QLineEdit as _QLineEdit
|
|
||||||
|
from .common import (
|
||||||
|
N_,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
|
BalTimestamp,
|
||||||
|
Buttons,
|
||||||
|
CancelButton,
|
||||||
|
HelpButton,
|
||||||
|
MessageBoxMixin,
|
||||||
|
MyTreeView,
|
||||||
|
OkButton,
|
||||||
|
QAbstractItemView,
|
||||||
|
QApplication,
|
||||||
|
QColor,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMenu,
|
||||||
|
QModelIndex,
|
||||||
|
QPersistentModelIndex,
|
||||||
|
QPushButton,
|
||||||
|
QSize,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStandardItem,
|
||||||
|
QStandardItemModel,
|
||||||
|
Qt,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
TaskThread,
|
||||||
|
Util,
|
||||||
|
Will,
|
||||||
|
Willexecutors,
|
||||||
|
WillItem,
|
||||||
|
_,
|
||||||
|
_logger,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
datetime,
|
||||||
|
enum,
|
||||||
|
export_meta_gui,
|
||||||
|
format_status_history,
|
||||||
|
getOpenFileName,
|
||||||
|
import_meta_gui,
|
||||||
|
is_op_return_address,
|
||||||
|
partial,
|
||||||
|
read_json_file,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
server_status_text,
|
||||||
|
server_status_tooltip,
|
||||||
|
signature_suffix,
|
||||||
|
status_color,
|
||||||
|
translated_headers,
|
||||||
|
tx_from_any,
|
||||||
|
write_json_file,
|
||||||
|
)
|
||||||
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):
|
||||||
@@ -29,9 +131,9 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
AMOUNT = enum.auto()
|
AMOUNT = enum.auto()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
Columns.NAME: _("Name"),
|
Columns.NAME: N_("Name"),
|
||||||
Columns.ADDRESS: _("Address"),
|
Columns.ADDRESS: N_("Address"),
|
||||||
Columns.AMOUNT: _("Amount"),
|
Columns.AMOUNT: N_("Amount"),
|
||||||
}
|
}
|
||||||
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
filter_columns = [Columns.NAME, Columns.ADDRESS]
|
||||||
|
|
||||||
@@ -84,7 +186,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
self.bal_window.new_heir_dialog(edit_key)
|
self.bal_window.new_heir_dialog(edit_key)
|
||||||
|
|
||||||
def on_edited(self, idx, edit_key, *, text):
|
def on_edited(self, idx, edit_key, *, text):
|
||||||
original = prior_name = self.bal_window.heirs.get(edit_key)
|
prior_name = self.bal_window.heirs.get(edit_key)
|
||||||
if not prior_name:
|
if not prior_name:
|
||||||
return
|
return
|
||||||
col = idx.column()
|
col = idx.column()
|
||||||
@@ -105,12 +207,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
try:
|
try:
|
||||||
self.bal_window.set_heir(prior_name)
|
self.bal_window.set_heir(prior_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
self.update()
|
||||||
|
|
||||||
try:
|
|
||||||
self.bal_window.set_heir((edit_key,) + original)
|
|
||||||
except Exception:
|
|
||||||
self.update()
|
|
||||||
|
|
||||||
def delete_heirs(self, selected_keys):
|
def delete_heirs(self, selected_keys):
|
||||||
self.bal_window.delete_heirs(selected_keys)
|
self.bal_window.delete_heirs(selected_keys)
|
||||||
@@ -151,13 +248,23 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
|
||||||
)
|
)
|
||||||
self.model().clear()
|
self.model().clear()
|
||||||
self.update_headers(self.__class__.headers)
|
self.update_headers(translated_headers(self.__class__.headers))
|
||||||
set_current = None
|
set_current = None
|
||||||
for key in sorted(self.bal_window.heirs.keys()):
|
for key in sorted(self.bal_window.heirs.keys()):
|
||||||
heir = self.bal_window.heirs[key]
|
heir = self.bal_window.heirs[key]
|
||||||
labels = [""] * len(self.Columns)
|
labels = [""] * len(self.Columns)
|
||||||
labels[self.Columns.NAME] = key
|
labels[self.Columns.NAME] = key
|
||||||
labels[self.Columns.ADDRESS] = heir[0]
|
if is_op_return_address(heir[0]):
|
||||||
|
data_hex = heir[0][len(OP_RETURN_PREFIX):]
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(data_hex).decode("utf-8", errors="replace")
|
||||||
|
if len(decoded) > 40:
|
||||||
|
decoded = decoded[:40] + "\u2026"
|
||||||
|
labels[self.Columns.ADDRESS] = decoded
|
||||||
|
except Exception:
|
||||||
|
labels[self.Columns.ADDRESS] = "OP_RETURN"
|
||||||
|
else:
|
||||||
|
labels[self.Columns.ADDRESS] = heir[0]
|
||||||
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
labels[self.Columns.AMOUNT] = Util.decode_amount(
|
||||||
heir[1], self.decimal_point
|
heir[1], self.decimal_point
|
||||||
)
|
)
|
||||||
@@ -184,7 +291,7 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
set_current = QPersistentModelIndex(idx)
|
set_current = QPersistentModelIndex(idx)
|
||||||
try:
|
try:
|
||||||
self.will_settings_widget.on_locktime_change()
|
self.will_settings_widget.on_locktime_change()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.set_current_idx(set_current)
|
self.set_current_idx(set_current)
|
||||||
# FIXME refresh loses sort order; so set "default" here:
|
# FIXME refresh loses sort order; so set "default" here:
|
||||||
@@ -204,15 +311,15 @@ class HeirListWidget(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
menu.addAction(_("Import"), self.bal_window.import_heirs)
|
||||||
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
|
||||||
|
|
||||||
newHeirButton = QPushButton(_("New Heir"))
|
new_heir_button = QPushButton(_("New Heir"))
|
||||||
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
|
new_heir_button.clicked.connect(self.bal_window.new_heir_dialog)
|
||||||
|
|
||||||
widget = QWidget(self)
|
widget = QWidget(self)
|
||||||
layout = QHBoxLayout(widget)
|
layout = QHBoxLayout(widget)
|
||||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||||
|
|
||||||
layout.addWidget(self.will_settings_widget)
|
layout.addWidget(self.will_settings_widget)
|
||||||
layout.addWidget(newHeirButton)
|
layout.addWidget(new_heir_button)
|
||||||
|
|
||||||
toolbar.insertWidget(2, widget)
|
toolbar.insertWidget(2, widget)
|
||||||
|
|
||||||
@@ -233,11 +340,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
SERVER = enum.auto()
|
SERVER = enum.auto()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
Columns.LOCKTIME: _("Locktime"),
|
Columns.LOCKTIME: N_("Locktime"),
|
||||||
Columns.TXID: _("Txid"),
|
Columns.TXID: N_("Txid"),
|
||||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
Columns.WILLEXECUTOR: N_("Will-Executor"),
|
||||||
Columns.STATUS: _("Status"),
|
Columns.STATUS: N_("Status"),
|
||||||
Columns.SERVER: _("Server"),
|
Columns.SERVER: N_("Server"),
|
||||||
}
|
}
|
||||||
|
|
||||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||||
@@ -271,7 +378,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
self.setModel(QStandardItemModel(self))
|
self.setModel(QStandardItemModel(self))
|
||||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
@@ -297,36 +404,64 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
selected_keys.append(sel_key)
|
selected_keys.append(sel_key)
|
||||||
if selected_keys and idx.isValid():
|
if selected_keys and idx.isValid():
|
||||||
column_title = self.model().horizontalHeaderItem(column).text()
|
column_title = self.model().horizontalHeaderItem(column).text()
|
||||||
# column_data = "\n".join(
|
|
||||||
# self.model().itemFromIndex(s_idx).text()
|
|
||||||
# for s_idx in self.selected_in_column(column)
|
|
||||||
# )
|
|
||||||
|
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("details").format(column_title),
|
_("details").format(column_title),
|
||||||
lambda: self.show_transaction(selected_keys),
|
lambda: self.show_transaction(selected_keys),
|
||||||
).setEnabled(len(selected_keys) < 2)
|
).setEnabled(len(selected_keys) == 1)
|
||||||
|
menu.addAction(
|
||||||
|
_("sign").format(column_title),
|
||||||
|
lambda: self.sign_transactions(selected_keys),
|
||||||
|
).setEnabled(
|
||||||
|
any(_can_sign(self.will.get(k)) for k in selected_keys)
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
_("broadcast").format(column_title),
|
||||||
|
lambda: self.broadcast_transactions(selected_keys),
|
||||||
|
).setEnabled(
|
||||||
|
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
|
||||||
|
)
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("check ").format(column_title),
|
_("check ").format(column_title),
|
||||||
lambda: self.check_transactions(selected_keys),
|
lambda: self.check_transactions(selected_keys),
|
||||||
)
|
)
|
||||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
|
||||||
try:
|
menu.addSeparator()
|
||||||
self.importaction = self.menu.addAction(
|
menu.addAction(
|
||||||
_("Import"), self.import_will
|
_("copy id").format(column_title),
|
||||||
)
|
lambda: self.copy_txids(selected_keys),
|
||||||
except Exception:
|
)
|
||||||
pass
|
menu.addAction(
|
||||||
|
_("copy").format(column_title),
|
||||||
|
lambda: self.copy_tx_hexes(selected_keys),
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
_("merge from txn").format(column_title),
|
||||||
|
lambda: self.merge_from_txn(),
|
||||||
|
)
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
||||||
|
).setEnabled(
|
||||||
|
any(_can_delete(self.will.get(k)) for k in selected_keys)
|
||||||
)
|
)
|
||||||
|
|
||||||
menu.exec(self.viewport().mapToGlobal(position))
|
menu.exec(self.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def is_deletable(self, key):
|
||||||
|
"""True if the will transaction ``key`` may be deleted.
|
||||||
|
|
||||||
|
Deletion is only allowed for transactions that are NOT valid or NOT
|
||||||
|
fully signed (invalidated/replaced/mempool/confirmed items, or
|
||||||
|
unsigned/partially signed ones). Valid and complete transactions are
|
||||||
|
kept (deleting them would silently drop a committed inheritance).
|
||||||
|
"""
|
||||||
|
return _can_delete(self.will.get(key))
|
||||||
|
|
||||||
def delete(self, selected_keys):
|
def delete(self, selected_keys):
|
||||||
for key in selected_keys:
|
keys = [k for k in selected_keys if self.is_deletable(k)]
|
||||||
|
for key in keys:
|
||||||
del self.will[key]
|
del self.will[key]
|
||||||
try:
|
try:
|
||||||
del self.bal_window.willitems[key]
|
del self.bal_window.willitems[key]
|
||||||
@@ -338,6 +473,75 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
pass
|
pass
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def sign_transactions(self, selected_keys):
|
||||||
|
"""Sign all selected transactions that are not fully signed yet."""
|
||||||
|
keys = [
|
||||||
|
k for k in selected_keys
|
||||||
|
if _can_sign(self.will.get(k))
|
||||||
|
]
|
||||||
|
if keys:
|
||||||
|
self.bal_window.ask_password_and_sign_transactions(
|
||||||
|
callback=self.update, txids=keys
|
||||||
|
)
|
||||||
|
|
||||||
|
def broadcast_transactions(self, selected_keys):
|
||||||
|
"""Force-broadcast all selected fully-signed transactions.
|
||||||
|
|
||||||
|
``force=True`` makes the will-executors re-accept transactions that
|
||||||
|
were already pushed before.
|
||||||
|
"""
|
||||||
|
keys = [
|
||||||
|
k for k in selected_keys
|
||||||
|
if _can_broadcast(self.will.get(k))
|
||||||
|
]
|
||||||
|
if keys:
|
||||||
|
self.bal_window.broadcast_transactions(force=True, txids=keys)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def copy_txids(self, selected_keys):
|
||||||
|
"""Copy the selected transaction IDs to the clipboard (one per line)."""
|
||||||
|
self.place_text_on_clipboard(
|
||||||
|
"\n".join(selected_keys), title=_("Transaction IDs")
|
||||||
|
)
|
||||||
|
|
||||||
|
def copy_tx_hexes(self, selected_keys):
|
||||||
|
"""Copy the selected transactions (serialised hex) to the clipboard."""
|
||||||
|
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
|
||||||
|
self.place_text_on_clipboard(hexes, title=_("Transactions"))
|
||||||
|
|
||||||
|
def merge_from_txn(self):
|
||||||
|
"""Merge a transaction read from the clipboard, or from a file if the
|
||||||
|
clipboard does not contain a valid transaction.
|
||||||
|
"""
|
||||||
|
tx = None
|
||||||
|
try:
|
||||||
|
tx = tx_from_any(QApplication.clipboard().text())
|
||||||
|
except Exception:
|
||||||
|
tx = None
|
||||||
|
if tx is None:
|
||||||
|
filename = getOpenFileName(
|
||||||
|
parent=self.bal_window.window,
|
||||||
|
title=_("Open transaction file"),
|
||||||
|
filter="All files (*)",
|
||||||
|
config=self.bal_window.window.config,
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(filename, "r") as f:
|
||||||
|
data = f.read()
|
||||||
|
tx = tx_from_any(data)
|
||||||
|
except Exception as e:
|
||||||
|
self.bal_window.show_error(
|
||||||
|
_("Invalid transaction file: {}").format(e)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.bal_window.merge_single_transaction(tx)
|
||||||
|
except Exception as e:
|
||||||
|
self.bal_window.show_error(str(e))
|
||||||
|
self.update()
|
||||||
|
|
||||||
def check_transactions(self, selected_keys):
|
def check_transactions(self, selected_keys):
|
||||||
wout = {}
|
wout = {}
|
||||||
for k in selected_keys:
|
for k in selected_keys:
|
||||||
@@ -385,8 +589,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
if bal_tx.we:
|
if bal_tx.we:
|
||||||
we = bal_tx.we["url"]
|
we = bal_tx.we["url"]
|
||||||
labels[self.Columns.WILLEXECUTOR] = we
|
labels[self.Columns.WILLEXECUTOR] = we
|
||||||
status = bal_tx.status
|
status = format_status_history(bal_tx.status) + signature_suffix(bal_tx)
|
||||||
if len(bal_tx.status) > 53:
|
if len(status) > 53:
|
||||||
status = "...{}".format(status[-50:])
|
status = "...{}".format(status[-50:])
|
||||||
labels[self.Columns.STATUS] = status
|
labels[self.Columns.STATUS] = status
|
||||||
# Dedicated, always-readable label describing whether the inheritance
|
# Dedicated, always-readable label describing whether the inheritance
|
||||||
@@ -443,7 +647,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
|
||||||
)
|
)
|
||||||
self.model().clear()
|
self.model().clear()
|
||||||
self.update_headers(self.__class__.headers)
|
self.update_headers(translated_headers(self.__class__.headers))
|
||||||
|
|
||||||
set_current = None
|
set_current = None
|
||||||
for txid, bal_tx in self.will.items():
|
for txid, bal_tx in self.will.items():
|
||||||
@@ -462,9 +666,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
menu.addAction(_("Prepare"), self.build_transactions)
|
menu.addAction(_("Prepare"), self.build_transactions)
|
||||||
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
|
||||||
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
|
||||||
|
# Export/Import open a single window that offers all transports
|
||||||
|
# (file / QR / audio). The Choose Filter / transport settings live
|
||||||
|
# inside that window.
|
||||||
menu.addAction(_("Export"), self.export_will)
|
menu.addAction(_("Export"), self.export_will)
|
||||||
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
|
menu.addAction(_("Import"), self.import_will)
|
||||||
self.importaction = menu.addAction(_("Import"), self.import_will)
|
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)
|
||||||
@@ -472,7 +679,7 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
# The Wizard is the main entry point to create an inheritance, so make
|
# The Wizard is the main entry point to create an inheritance, so make
|
||||||
# it stand out: show a bold label next to a slightly larger icon (the
|
# it stand out: show a bold label next to a slightly larger icon (the
|
||||||
# plain icon-only button was too easy to overlook).
|
# plain icon-only button was too easy to overlook).
|
||||||
wizard = QPushButton(" " + _("Build Your Will"))
|
wizard = QPushButton(" {}".format(_("Build Your Will")))
|
||||||
wizard.setIcon(
|
wizard.setIcon(
|
||||||
read_QIcon_from_bytes(
|
read_QIcon_from_bytes(
|
||||||
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
||||||
@@ -529,15 +736,17 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
if will:
|
if will:
|
||||||
self.update_will(will)
|
self.update_will(will)
|
||||||
|
|
||||||
def export_json_file(self, path):
|
|
||||||
write_json_file(path, self.will)
|
|
||||||
|
|
||||||
def export_will(self):
|
def export_will(self):
|
||||||
self.bal_window.export_will()
|
self.bal_window.export_will_dialog()
|
||||||
self.update()
|
|
||||||
|
|
||||||
def import_will(self):
|
def import_will(self):
|
||||||
self.bal_window.import_will()
|
self.bal_window.import_will_dialog()
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -698,12 +907,12 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
ADDRESS = enum.auto()
|
ADDRESS = enum.auto()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
Columns.SELECTED: _(""),
|
Columns.SELECTED: "",
|
||||||
Columns.URL: _("Url"),
|
Columns.URL: N_("Url"),
|
||||||
Columns.STATUS: _("S"),
|
Columns.STATUS: N_("S"),
|
||||||
Columns.BASE_FEE: _("Base fee"),
|
Columns.BASE_FEE: N_("Base fee"),
|
||||||
Columns.INFO: _("Info"),
|
Columns.INFO: N_("Info"),
|
||||||
Columns.ADDRESS: _("Default Address"),
|
Columns.ADDRESS: N_("Default Address"),
|
||||||
}
|
}
|
||||||
|
|
||||||
filter_columns = [Columns.URL]
|
filter_columns = [Columns.URL]
|
||||||
@@ -756,6 +965,7 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
idx = self.indexAt(position)
|
idx = self.indexAt(position)
|
||||||
column = idx.column() or self.Columns.URL
|
column = idx.column() or self.Columns.URL
|
||||||
selected_keys = []
|
selected_keys = []
|
||||||
|
sel_key = None
|
||||||
for s_idx in self.selected_in_column(self.Columns.URL):
|
for s_idx in self.selected_in_column(self.Columns.URL):
|
||||||
item = self.model().itemFromIndex(s_idx)
|
item = self.model().itemFromIndex(s_idx)
|
||||||
# Use the FULL url stored in the key role, NOT item.data(0): the
|
# Use the FULL url stored in the key role, NOT item.data(0): the
|
||||||
@@ -771,6 +981,15 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
# self.model().itemFromIndex(s_idx).text()
|
# self.model().itemFromIndex(s_idx).text()
|
||||||
# for s_idx in self.selected_in_column(column)
|
# for s_idx in self.selected_in_column(column)
|
||||||
# )
|
# )
|
||||||
|
# When exactly ONE cell is selected, offer "Copy" to copy the value
|
||||||
|
# of that cell to the clipboard (e.g. a single url / address / fee).
|
||||||
|
# This list has no ``main_window``, so use QApplication directly.
|
||||||
|
if len(self.selectionModel().selectedIndexes()) == 1:
|
||||||
|
cell_value = self.model().itemFromIndex(idx).text()
|
||||||
|
menu.addAction(
|
||||||
|
_("Copy"),
|
||||||
|
lambda: QApplication.clipboard().setText(cell_value),
|
||||||
|
)
|
||||||
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("deselect").format(column_title),
|
_("deselect").format(column_title),
|
||||||
@@ -875,7 +1094,7 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
|
||||||
)
|
)
|
||||||
self.model().clear()
|
self.model().clear()
|
||||||
self.update_headers(self.__class__.headers)
|
self.update_headers(translated_headers(self.__class__.headers))
|
||||||
|
|
||||||
set_current = None
|
set_current = None
|
||||||
|
|
||||||
@@ -929,6 +1148,17 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
items.append(QStandardItem(e))
|
items.append(QStandardItem(e))
|
||||||
|
|
||||||
|
max_fee = self._bal_parent.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
||||||
|
dust = self._bal_parent.bal_window.window.wallet.dust_threshold()
|
||||||
|
if not Willexecutors.is_valid(value, max_fee=max_fee, dust=dust):
|
||||||
|
grey = QColor("#808080")
|
||||||
|
for item in items:
|
||||||
|
font = item.font()
|
||||||
|
font.setItalic(True)
|
||||||
|
item.setFont(font)
|
||||||
|
item.setForeground(grey)
|
||||||
|
|
||||||
items[self.Columns.SELECTED].setEditable(False)
|
items[self.Columns.SELECTED].setEditable(False)
|
||||||
items[self.Columns.URL].setEditable(True)
|
items[self.Columns.URL].setEditable(True)
|
||||||
items[self.Columns.ADDRESS].setEditable(True)
|
items[self.Columns.ADDRESS].setEditable(True)
|
||||||
@@ -1008,13 +1238,45 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
b.clicked.connect(self.import_file)
|
b.clicked.connect(self.import_file)
|
||||||
buttonbox.addWidget(b)
|
buttonbox.addWidget(b)
|
||||||
|
|
||||||
b = QPushButton(_("Export"))
|
def _menu_button(label):
|
||||||
b.clicked.connect(self.export_file)
|
# ``label`` must already be translated by the caller.
|
||||||
buttonbox.addWidget(b)
|
btn = QToolButton()
|
||||||
|
btn.setText(label)
|
||||||
|
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
|
buttonbox.addWidget(btn)
|
||||||
|
return btn
|
||||||
|
|
||||||
b = QPushButton(_("Ping All"))
|
export_btn = _menu_button(_("Export"))
|
||||||
b.clicked.connect(self.update_willexecutors)
|
export_menu = QMenu(export_btn)
|
||||||
buttonbox.addWidget(b)
|
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
||||||
|
export_menu.addAction(
|
||||||
|
_("Export selected"), lambda: self.export_file(subset="selected")
|
||||||
|
)
|
||||||
|
export_menu.addAction(
|
||||||
|
_("Export only valid"), lambda: self.export_file(subset="valid")
|
||||||
|
)
|
||||||
|
export_btn.setMenu(export_menu)
|
||||||
|
|
||||||
|
ping_btn = _menu_button(_("Ping All"))
|
||||||
|
ping_menu = QMenu(ping_btn)
|
||||||
|
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
||||||
|
ping_menu.addAction(
|
||||||
|
_("Ping selected"), lambda: self.ping_selected_willexecutors()
|
||||||
|
)
|
||||||
|
ping_btn.setMenu(ping_menu)
|
||||||
|
|
||||||
|
select_btn = _menu_button(_("Select All"))
|
||||||
|
select_menu = QMenu(select_btn)
|
||||||
|
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
||||||
|
select_menu.addAction(
|
||||||
|
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
|
||||||
|
)
|
||||||
|
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
|
||||||
|
select_menu.addAction(
|
||||||
|
_("Deselect only invalid"),
|
||||||
|
lambda: self.set_select_all(False, only_valid=True),
|
||||||
|
)
|
||||||
|
select_btn.setMenu(select_menu)
|
||||||
|
|
||||||
vbox.addLayout(buttonbox)
|
vbox.addLayout(buttonbox)
|
||||||
# self.will_executor_list_widget.update()
|
# self.will_executor_list_widget.update()
|
||||||
@@ -1037,7 +1299,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
url_edit = QLineEdit()
|
url_edit = QLineEdit()
|
||||||
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
url_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||||
info_edit = QLineEdit("New Will Executor")
|
info_edit = QLineEdit(_("New Will Executor"))
|
||||||
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
info_edit.setFixedWidth(32 * char_width_in_lineedit())
|
||||||
base_fee_spin = QSpinBox()
|
base_fee_spin = QSpinBox()
|
||||||
base_fee_spin.setRange(0, 1000000)
|
base_fee_spin.setRange(0, 1000000)
|
||||||
@@ -1051,7 +1313,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
if executor:
|
if executor:
|
||||||
url_edit.setText(edit_key)
|
url_edit.setText(edit_key)
|
||||||
info_edit.setText(str(executor.get("info", "New Will Executor")))
|
info_edit.setText(str(executor.get("info", _("New Will Executor"))))
|
||||||
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
base_fee_spin.setValue(int(executor.get("base_fee", 0)))
|
||||||
address_edit.setText(str(executor.get("address", "")))
|
address_edit.setText(str(executor.get("address", "")))
|
||||||
|
|
||||||
@@ -1135,6 +1397,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
add_another_btn.clicked.connect(add_another)
|
add_another_btn.clicked.connect(add_another)
|
||||||
else:
|
else:
|
||||||
self._add_another = False
|
self._add_another = False
|
||||||
|
add_another_btn = None
|
||||||
|
|
||||||
row = 0
|
row = 0
|
||||||
grid.addWidget(QLabel(_("URL")), row, 0)
|
grid.addWidget(QLabel(_("URL")), row, 0)
|
||||||
@@ -1210,7 +1473,7 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
break
|
break
|
||||||
self._add_another = False
|
self._add_another = False
|
||||||
url_edit.clear()
|
url_edit.clear()
|
||||||
info_edit.setText("New Will Executor")
|
info_edit.setText(_("New Will Executor"))
|
||||||
base_fee_spin.setValue(0)
|
base_fee_spin.setValue(0)
|
||||||
address_edit.clear()
|
address_edit.clear()
|
||||||
|
|
||||||
@@ -1227,13 +1490,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
||||||
|
|
||||||
def export_file(self, path):
|
def export_file(self, subset=None):
|
||||||
|
data = self.export_data(subset)
|
||||||
|
if subset and not data:
|
||||||
|
self.show_message(_("No will-executor matches the selected filter"))
|
||||||
|
return
|
||||||
export_meta_gui(
|
export_meta_gui(
|
||||||
self.bal_window.window, "willexecutors.json", self.export_json_file
|
self.bal_window.window,
|
||||||
|
"willexecutors.json",
|
||||||
|
partial(self.export_json_file, subset=subset),
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_json_file(self, path):
|
def export_data(self, subset=None):
|
||||||
write_json_file(path, self.willexecutors_list)
|
data = self.willexecutors_list
|
||||||
|
if subset == "selected":
|
||||||
|
data = {
|
||||||
|
url: we
|
||||||
|
for url, we in data.items()
|
||||||
|
if Willexecutors.is_selected(we)
|
||||||
|
}
|
||||||
|
elif subset == "valid":
|
||||||
|
valid = self._validity()
|
||||||
|
data = {
|
||||||
|
url: we for url, we in data.items() if valid.get(url, False)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def export_json_file(self, path, subset=None):
|
||||||
|
write_json_file(path, self.export_data(subset))
|
||||||
|
|
||||||
|
def _validity(self):
|
||||||
|
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
||||||
|
dust = self.bal_window.window.wallet.dust_threshold()
|
||||||
|
return {
|
||||||
|
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
|
||||||
|
for url, we in self.willexecutors_list.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _selected_willexecutors(self):
|
||||||
|
return {
|
||||||
|
url: we
|
||||||
|
for url, we in self.willexecutors_list.items()
|
||||||
|
if Willexecutors.is_selected(we)
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_select_all(self, select, only_valid=False):
|
||||||
|
"""Apply a bulk selection across all will-executors.
|
||||||
|
|
||||||
|
``select=True`` selects all (or only the valid ones when
|
||||||
|
``only_valid=True``, deselecting the invalid ones); ``select=False``
|
||||||
|
deselects all (or only the invalid ones when ``only_valid=True``,
|
||||||
|
leaving the valid ones selected).
|
||||||
|
"""
|
||||||
|
valid = self._validity() if only_valid else None
|
||||||
|
_apply_select_all(self.willexecutors_list, select, valid)
|
||||||
|
self.save_willexecutors()
|
||||||
|
|
||||||
|
def ping_selected_willexecutors(self):
|
||||||
|
wes = self._selected_willexecutors()
|
||||||
|
if not wes:
|
||||||
|
self.show_message(_("No will-executor is selected"))
|
||||||
|
return
|
||||||
|
self.update_willexecutors(wes)
|
||||||
|
|
||||||
def import_file(self):
|
def import_file(self):
|
||||||
import_meta_gui(
|
import_meta_gui(
|
||||||
|
|||||||
@@ -15,14 +15,40 @@ and cached in ``self.bal_windows``.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from electrum.gui.qt.main_window import StatusBarButton
|
from electrum.gui.qt.main_window import StatusBarButton
|
||||||
|
from electrum.plugin import hook
|
||||||
|
from electrum.util import EventListener, event_listener
|
||||||
|
from PyQt6.QtWidgets import QLayout
|
||||||
|
|
||||||
from .common import *
|
from ...i18n import init_from_config
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import (
|
||||||
from .common import read_QIcon_from_bytes
|
BalPlugin,
|
||||||
|
Buttons,
|
||||||
|
EnterButton,
|
||||||
|
HelpButton,
|
||||||
|
PasswordDialog,
|
||||||
|
QComboBox,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTimer,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
UserCancelled,
|
||||||
|
Willexecutors,
|
||||||
|
_,
|
||||||
|
_logger,
|
||||||
|
add_widget,
|
||||||
|
partial,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
read_QPixmap_from_bytes,
|
||||||
|
show_modal,
|
||||||
|
webopen,
|
||||||
|
)
|
||||||
|
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):
|
||||||
@@ -37,15 +63,24 @@ def _window_key(window):
|
|||||||
return id(window)
|
return id(window)
|
||||||
|
|
||||||
|
|
||||||
class Plugin(BalPlugin):
|
class Plugin(BalPlugin, EventListener):
|
||||||
def __init__(self, parent, config, name):
|
def __init__(self, parent, config, name):
|
||||||
_logger.info("INIT BALPLUGIN")
|
_logger.info("INIT BALPLUGIN")
|
||||||
BalPlugin.__init__(self, parent, config, name)
|
BalPlugin.__init__(self, parent, config, name)
|
||||||
|
# Load BAL's catalog for the language Electrum's GUI is using, before
|
||||||
|
# any window or dialog builds its texts. It must come after
|
||||||
|
# BalPlugin.__init__: read_file() needs the plugin's parent and name.
|
||||||
|
# The CLI plugin does not call this, so the CLI stays in English.
|
||||||
|
init_from_config(self, config)
|
||||||
self.bal_windows = {}
|
self.bal_windows = {}
|
||||||
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
||||||
# remove a stale button before creating a fresh one when a wallet is
|
# remove a stale button before creating a fresh one when a wallet is
|
||||||
# switched / Electrum is restarted, so the icon is never duplicated.
|
# switched / Electrum is restarted, so the icon is never duplicated.
|
||||||
self._statusbar_buttons = {}
|
self._statusbar_buttons = {}
|
||||||
|
# Register the on_event_* handlers with Electrum's callback manager so
|
||||||
|
# the plugin learns about new wallet transactions (used by the
|
||||||
|
# AUTO_REBUILD setting).
|
||||||
|
self.register_callbacks()
|
||||||
|
|
||||||
@hook
|
@hook
|
||||||
def init_qt(self, gui_object):
|
def init_qt(self, gui_object):
|
||||||
@@ -88,9 +123,11 @@ class Plugin(BalPlugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
from electrum.gui.qt.plugins_dialog import PluginsDialog
|
from electrum.gui.qt.plugins_dialog import (
|
||||||
|
PluginsDialog as plugins_dialog, # noqa: N813
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
PluginsDialog = None
|
plugins_dialog = None
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if app is None:
|
||||||
return []
|
return []
|
||||||
@@ -106,7 +143,7 @@ class Plugin(BalPlugin):
|
|||||||
for w in app.topLevelWidgets():
|
for w in app.topLevelWidgets():
|
||||||
try:
|
try:
|
||||||
is_match = False
|
is_match = False
|
||||||
if PluginsDialog is not None and isinstance(w, PluginsDialog):
|
if plugins_dialog is not None and isinstance(w, plugins_dialog):
|
||||||
is_match = True
|
is_match = True
|
||||||
elif type(w).__name__ == "PluginsDialog":
|
elif type(w).__name__ == "PluginsDialog":
|
||||||
is_match = True
|
is_match = True
|
||||||
@@ -142,17 +179,17 @@ class Plugin(BalPlugin):
|
|||||||
each is guarded independently.
|
each is guarded independently.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtWidgets import QDialog
|
from PyQt6.QtWidgets import QDialog as qdialog # noqa: N813
|
||||||
except Exception:
|
except Exception:
|
||||||
QDialog = None
|
qdialog = None
|
||||||
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
||||||
if QDialog is not None and isinstance(d, QDialog):
|
if qdialog is not None and isinstance(d, qdialog):
|
||||||
try:
|
try:
|
||||||
d.reject()
|
d.reject()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("reject() failed: {}".format(e))
|
_logger.debug("reject() failed: {}".format(e))
|
||||||
try:
|
try:
|
||||||
d.done(QDialog.DialogCode.Rejected)
|
d.done(qdialog.DialogCode.Rejected)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.debug("done() failed: {}".format(e))
|
_logger.debug("done() failed: {}".format(e))
|
||||||
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
||||||
@@ -172,9 +209,9 @@ class Plugin(BalPlugin):
|
|||||||
it and closes it themselves (it must not linger in the background).
|
it and closes it themselves (it must not linger in the background).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from PyQt6.QtCore import QTimer
|
from PyQt6.QtCore import QTimer as qtimer # noqa: N813
|
||||||
except Exception:
|
except Exception:
|
||||||
QTimer = None
|
qtimer = None
|
||||||
# Schedule of retry delays (ms) measured from each call.
|
# Schedule of retry delays (ms) measured from each call.
|
||||||
retry_delays = [400, 800, 1500]
|
retry_delays = [400, 800, 1500]
|
||||||
dialogs = Plugin._find_plugins_manager_dialogs()
|
dialogs = Plugin._find_plugins_manager_dialogs()
|
||||||
@@ -190,8 +227,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),
|
||||||
)
|
)
|
||||||
@@ -266,7 +303,7 @@ class Plugin(BalPlugin):
|
|||||||
pass
|
pass
|
||||||
b = StatusBarButton(
|
b = StatusBarButton(
|
||||||
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
||||||
"Bal " + _("Bitcoin After Life"),
|
"Bal {}".format(_("Bitcoin After Life")),
|
||||||
lambda: self.settings_dialog(sb.window()),
|
lambda: self.settings_dialog(sb.window()),
|
||||||
sb.height(),
|
sb.height(),
|
||||||
)
|
)
|
||||||
@@ -323,6 +360,44 @@ class Plugin(BalPlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error("close_wallet: on_close failed: {}".format(e))
|
_logger.error("close_wallet: on_close failed: {}".format(e))
|
||||||
|
|
||||||
|
@event_listener
|
||||||
|
def on_event_new_transaction(self, wallet, tx):
|
||||||
|
"""Electrum event: a transaction was added to *wallet*."""
|
||||||
|
self._wallet_activity(wallet)
|
||||||
|
|
||||||
|
@event_listener
|
||||||
|
def on_event_wallet_updated(self, wallet):
|
||||||
|
"""Electrum event: *wallet* finished a sync pass."""
|
||||||
|
self._wallet_activity(wallet)
|
||||||
|
|
||||||
|
def _wallet_activity(self, wallet):
|
||||||
|
"""React to wallet activity (new transaction / sync update).
|
||||||
|
|
||||||
|
When the AUTO_REBUILD setting is enabled, any change to a wallet that
|
||||||
|
has a live BalWindow schedules the headless "auto rebuild" flow
|
||||||
|
(``BalWindow.schedule_auto_rebuild``): it re-runs the same check the
|
||||||
|
wizard runs at wallet close, anticipating the delivery date by one day
|
||||||
|
and building an on-chain invalidation tx only when the anticipated
|
||||||
|
locktime would fall before the check-alive threshold (or the threshold
|
||||||
|
is already in the past).
|
||||||
|
|
||||||
|
This handler runs on the asyncio callback thread, so it only touches
|
||||||
|
thread-safe state and defers all work to the BalWindow (which marshals
|
||||||
|
itself onto the GUI thread through QTimer).
|
||||||
|
"""
|
||||||
|
if not self.AUTO_REBUILD.get():
|
||||||
|
return
|
||||||
|
for win in list(self.bal_windows.values()):
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
getattr(win, "wallet", None) == wallet
|
||||||
|
and win.ok
|
||||||
|
and not win.disable_plugin
|
||||||
|
):
|
||||||
|
win.schedule_auto_rebuild()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.debug("_wallet_activity failed: {}".format(e))
|
||||||
|
|
||||||
@hook
|
@hook
|
||||||
def init_keystore(self):
|
def init_keystore(self):
|
||||||
_logger.debug("init keystore")
|
_logger.debug("init keystore")
|
||||||
@@ -372,7 +447,7 @@ class Plugin(BalPlugin):
|
|||||||
|
|
||||||
def settings_dialog(self, window=None, wallet=None):
|
def settings_dialog(self, window=None, wallet=None):
|
||||||
|
|
||||||
d = BalDialog(window, self, self.get_window_title("Settings"))
|
d = BalDialog(window, self, self.get_window_title(_("Settings")))
|
||||||
d.setMinimumSize(100, 200)
|
d.setMinimumSize(100, 200)
|
||||||
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
|
||||||
lbl_logo = QLabel()
|
lbl_logo = QLabel()
|
||||||
@@ -436,13 +511,31 @@ 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)
|
||||||
|
|
||||||
# "No will-executor TX" checkbox. Bound to the persisted NO_WILLEXECUTOR
|
# Max willexecutor fee spin box. Maximum fee (in satoshi) allowed for
|
||||||
# config (default ON, see plugin_base.py), the SAME config used by the
|
# a single will-executor. If a will-executor charges more, the will
|
||||||
# checkbox inside the "Build your will" wizard's will-executor download
|
# will not be built. Default 500,000 satoshi (0.005 BTC).
|
||||||
# window, so the two stay in sync automatically. When enabled the plugin
|
heir_max_willexecutor_fee = BalSpinBox(
|
||||||
# also builds a will that does not require a will-executor (e.g. it can
|
self.MAX_WILLEXECUTOR_FEE, minimum=0, maximum=10000000
|
||||||
# be saved on a USB stick and a copy given to the heirs).
|
)
|
||||||
heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
|
|
||||||
|
# "Rebuild will on wallet close" checkbox. Bound to the persisted
|
||||||
|
# REBUILD_ON_CLOSE config (default ON). When ticked, closing the wallet
|
||||||
|
# / quitting Electrum runs the "Build your will" wizard to rebuild and
|
||||||
|
# re-validate the will. When unticked, the will is only rebuilt when
|
||||||
|
# the user presses Check/Prepare. Visible to all users (BASIC and
|
||||||
|
# ADVANCED).
|
||||||
|
heir_rebuild_on_close = BalCheckBox(self.REBUILD_ON_CLOSE)
|
||||||
|
|
||||||
|
# "Rebuild automatically on new transactions" checkbox. Bound to the
|
||||||
|
# persisted AUTO_REBUILD config (default OFF). When ticked, an incoming
|
||||||
|
# or outgoing wallet transaction automatically re-runs the same rebuild
|
||||||
|
# flow the wizard runs at wallet close: the delivery date is
|
||||||
|
# anticipated by one day (so the new will replaces the previous one
|
||||||
|
# without an invalidation tx), and an on-chain invalidation is only
|
||||||
|
# built when the anticipated locktime would fall before the Check Alive
|
||||||
|
# threshold or the threshold is already in the past. Visible to all
|
||||||
|
# users (BASIC and ADVANCED).
|
||||||
|
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
|
||||||
|
|
||||||
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||||
# (not a free-text field) bound to the USER_TYPE config:
|
# (not a free-text field) bound to the USER_TYPE config:
|
||||||
@@ -498,8 +591,10 @@ class Plugin(BalPlugin):
|
|||||||
lbl_event_description, edit_event_description, help_event_description,
|
lbl_event_description, edit_event_description, help_event_description,
|
||||||
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
lbl_calendar_app, edit_calendar_app, help_calendar_app,
|
||||||
lbl_auto_sign, heir_auto_sign, help_auto_sign,
|
lbl_auto_sign, heir_auto_sign, help_auto_sign,
|
||||||
|
lbl_save_history, heir_save_history, help_save_history,
|
||||||
|
lbl_history_label, edit_history_label, help_history_label,
|
||||||
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
reset_btn_6, reset_btn_7, reset_btn_8, reset_btn_9, reset_btn_10,
|
||||||
reset_btn_auto_sign):
|
reset_btn_11, reset_btn_12, reset_btn_auto_sign):
|
||||||
w.setVisible(not basic)
|
w.setVisible(not basic)
|
||||||
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
# Opzione 2: apply the per-mode Raw/Date editor default ONLY here, on
|
||||||
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
# a real USER TYPE change (not inside update_all/CHECK), so pressing
|
||||||
@@ -525,6 +620,20 @@ class Plugin(BalPlugin):
|
|||||||
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
|
||||||
edit_calendar_app.setMinimumWidth(360)
|
edit_calendar_app.setMinimumWidth(360)
|
||||||
|
|
||||||
|
# "Save inheritance transactions in wallet history" checkbox + label
|
||||||
|
# field (History persistence). When the checkbox is ON, the valid will
|
||||||
|
# transactions are saved into the wallet's LOCAL history (the History
|
||||||
|
# tab) after each check, each tagged with the label below. The label
|
||||||
|
# field is disabled while the checkbox is off, so the user cannot set a
|
||||||
|
# label for a feature that is not active.
|
||||||
|
def on_save_history_change():
|
||||||
|
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
||||||
|
|
||||||
|
heir_save_history = BalCheckBox(self.SAVE_HISTORY, on_click=on_save_history_change)
|
||||||
|
edit_history_label = BalLineEdit(self.HISTORY_LABEL)
|
||||||
|
edit_history_label.setMinimumWidth(360)
|
||||||
|
edit_history_label.setEnabled(bool(self.SAVE_HISTORY.get()))
|
||||||
|
|
||||||
def _make_reset_btn(cfg, widget, kind):
|
def _make_reset_btn(cfg, widget, kind):
|
||||||
"""Return a small ``↺`` button that resets a single setting."""
|
"""Return a small ``↺`` button that resets a single setting."""
|
||||||
btn = QPushButton("\u21ba")
|
btn = QPushButton("\u21ba")
|
||||||
@@ -537,9 +646,9 @@ class Plugin(BalPlugin):
|
|||||||
elif kind == "spin":
|
elif kind == "spin":
|
||||||
widget.setValue(int(cfg.default))
|
widget.setValue(int(cfg.default))
|
||||||
elif kind == "line":
|
elif kind == "line":
|
||||||
widget.setText(cfg.default)
|
widget.setText(cfg.localized_default())
|
||||||
elif kind == "text":
|
elif kind == "text":
|
||||||
widget.setPlainText(cfg.default)
|
widget.setPlainText(cfg.localized_default())
|
||||||
elif kind == "user_type":
|
elif kind == "user_type":
|
||||||
widget.setCurrentIndex(
|
widget.setCurrentIndex(
|
||||||
1 if str(cfg.default).lower() == "advanced" else 0
|
1 if str(cfg.default).lower() == "advanced" else 0
|
||||||
@@ -547,7 +656,7 @@ class Plugin(BalPlugin):
|
|||||||
btn.clicked.connect(reset)
|
btn.clicked.connect(reset)
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
heir_repush = QPushButton("Rebroadcast transactions")
|
heir_repush = QPushButton(_("Rebroadcast transactions"))
|
||||||
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
|
||||||
bal_mode = QComboBox()
|
bal_mode = QComboBox()
|
||||||
options = ["Easy", "Advanced", "Experimental"]
|
options = ["Easy", "Advanced", "Experimental"]
|
||||||
@@ -573,27 +682,27 @@ class Plugin(BalPlugin):
|
|||||||
# advanced-only settings, so the user picks basic/advanced first.
|
# advanced-only settings, so the user picks basic/advanced first.
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Hide Replaced",
|
_("Hide Replaced"),
|
||||||
heir_hide_replaced,
|
heir_hide_replaced,
|
||||||
0,
|
0,
|
||||||
"Hide replaced transactions from will detail and list",
|
_("Hide replaced transactions from will detail and list"),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
|
grid.addWidget(_make_reset_btn(self.HIDE_REPLACED, heir_hide_replaced, "check"), 0, 3)
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Hide Invalidated",
|
_("Hide Invalidated"),
|
||||||
heir_hide_invalidated,
|
heir_hide_invalidated,
|
||||||
1,
|
1,
|
||||||
"Hide invalidated transactions from will detail and list",
|
_("Hide invalidated transactions from will detail and list"),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
|
grid.addWidget(_make_reset_btn(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"), 1, 3)
|
||||||
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
|
lbl_auto_sign = QLabel(_("Auto-sign on Check"))
|
||||||
help_auto_sign = HelpButton(
|
help_auto_sign = HelpButton(_(
|
||||||
"When checking, automatically sign and broadcast the will "
|
"When checking, automatically sign and broadcast the will "
|
||||||
"transactions to their will-executors.\n"
|
"transactions to their will-executors.\n"
|
||||||
"The wallet password is requested only if the wallet is "
|
"The wallet password is requested only if the wallet is "
|
||||||
"encrypted."
|
"encrypted."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
|
grid.addWidget(_hide_if_basic(lbl_auto_sign), 2, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
|
grid.addWidget(_hide_if_basic(heir_auto_sign), 2, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
|
grid.addWidget(_hide_if_basic(help_auto_sign), 2, 2)
|
||||||
@@ -603,10 +712,10 @@ class Plugin(BalPlugin):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
|
grid.addWidget(_hide_if_basic(reset_btn_auto_sign), 2, 3)
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Panel editable Date and Fee",
|
_("Panel editable Date and Fee"),
|
||||||
heir_editable_dates,
|
heir_editable_dates,
|
||||||
3,
|
3,
|
||||||
(
|
_(
|
||||||
"When enabled, the delivery-time and check-alive date fields "
|
"When enabled, the delivery-time and check-alive date fields "
|
||||||
"can be edited everywhere (toolbar / Heirs tab), not only in "
|
"can be edited everywhere (toolbar / Heirs tab), not only in "
|
||||||
"the will-building wizard.\n"
|
"the will-building wizard.\n"
|
||||||
@@ -614,36 +723,29 @@ class Plugin(BalPlugin):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
|
grid.addWidget(_make_reset_btn(self.EDITABLE_DATES, heir_editable_dates, "check"), 3, 3)
|
||||||
# "Add transaction without will-executor" setting (formerly labelled
|
# Max willexecutor fee: maximum fee (in satoshi) allowed for a single
|
||||||
# "No will-executor TX"). When ON the plugin ALSO builds the backup
|
# will-executor. Visible to all users (BASIC and ADVANCED).
|
||||||
# inheritance transaction that does NOT require a will-executor (the
|
|
||||||
# "celeste"/light-blue one shown in the will list): it can be saved on a
|
|
||||||
# USB stick and a copy handed to the heirs. When OFF only the
|
|
||||||
# transactions destined to the selected will-executors are built.
|
|
||||||
#
|
|
||||||
# Placed here (row 5, right below "Panel editable Date and Fee" and above
|
|
||||||
# "Number of reminders") at the user's request so related options sit
|
|
||||||
# together. The remaining grid rows below were renumbered accordingly.
|
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"Add transaction without willexecutor",
|
_("Max Will-Executor Fee (satoshi)"),
|
||||||
heir_no_willexecutor,
|
heir_max_willexecutor_fee,
|
||||||
4,
|
4,
|
||||||
(
|
_(
|
||||||
"Create a will that does not require a Will-executor; it can be "
|
"Maximum fee (in satoshi) allowed to be paid to a single "
|
||||||
"saved, for example, on a USB stick, and a copy can be given to "
|
"will-executor. If a will-executor charges more than this, "
|
||||||
"the heirs."
|
"the will will not be built.\n"
|
||||||
|
"Default: 500,000 satoshi (0.005 BTC)."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
grid.addWidget(_make_reset_btn(self.NO_WILLEXECUTOR, heir_no_willexecutor, "check"), 4, 3)
|
grid.addWidget(_make_reset_btn(self.MAX_WILLEXECUTOR_FEE, heir_max_willexecutor_fee, "spin"), 4, 3)
|
||||||
# User Type selector placed BEFORE the advanced-only settings so the
|
# User Type selector placed BEFORE the advanced-only settings so the
|
||||||
# user chooses basic/advanced first, then sees the relevant options.
|
# user chooses basic/advanced first, then sees the relevant options.
|
||||||
add_widget(
|
add_widget(
|
||||||
grid,
|
grid,
|
||||||
"User Type",
|
_("User Type"),
|
||||||
user_type_combo,
|
user_type_combo,
|
||||||
5,
|
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 "
|
||||||
"users.\n\n"
|
"users.\n\n"
|
||||||
@@ -658,10 +760,10 @@ class Plugin(BalPlugin):
|
|||||||
# 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.
|
||||||
lbl_num_reminders = QLabel(_("Number of reminders"))
|
lbl_num_reminders = QLabel(_("Number of reminders"))
|
||||||
help_num_reminders = HelpButton(
|
help_num_reminders = HelpButton(_(
|
||||||
"How many reminder alarms the exported calendar (.ics) event "
|
"How many reminder alarms the exported calendar (.ics) event "
|
||||||
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
"contains. Range: 1 to 5 (default 3). Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
grid.addWidget(_hide_if_basic(lbl_num_reminders), 6, 0)
|
||||||
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
grid.addWidget(_hide_if_basic(heir_num_reminders), 6, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
grid.addWidget(_hide_if_basic(help_num_reminders), 6, 2)
|
||||||
@@ -669,13 +771,13 @@ class Plugin(BalPlugin):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_6), 6, 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(_(
|
||||||
"Default message to be used in event summary\n"
|
"Default message to be used in event summary\n"
|
||||||
"Variables:\n"
|
"Variables:\n"
|
||||||
" $wallet_name: name of wallet\n"
|
" $wallet_name: name of wallet\n"
|
||||||
" $heirs_complete: list of heirs name,address,amount\n"
|
" $heirs_complete: list of heirs name,address,amount\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_summary), 7, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
grid.addWidget(_hide_if_basic(edit_event_summary), 7, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
grid.addWidget(_hide_if_basic(help_event_summary), 7, 2)
|
||||||
@@ -683,13 +785,13 @@ class Plugin(BalPlugin):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_7), 7, 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(_(
|
||||||
"Default message to be used in event description\n"
|
"Default message to be used in event description\n"
|
||||||
"Variables:\n"
|
"Variables:\n"
|
||||||
" $wallet_name: name of wallet\n"
|
" $wallet_name: name of wallet\n"
|
||||||
" $heirs_complete: list of heirs name,address,amount\n"
|
" $heirs_complete: list of heirs name,address,amount\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
grid.addWidget(_hide_if_basic(lbl_event_description), 8, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
grid.addWidget(_hide_if_basic(edit_event_description), 8, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
grid.addWidget(_hide_if_basic(help_event_description), 8, 2)
|
||||||
@@ -698,10 +800,10 @@ class Plugin(BalPlugin):
|
|||||||
# 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"))
|
||||||
help_welist_server = HelpButton(
|
help_welist_server = HelpButton(_(
|
||||||
"URL of the server that provides the will-executor list. "
|
"URL of the server that provides the will-executor list. "
|
||||||
"Only available in ADVANCED mode."
|
"Only available in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
grid.addWidget(_hide_if_basic(lbl_welist_server), 9, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
grid.addWidget(_hide_if_basic(edit_welist_server), 9, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
grid.addWidget(_hide_if_basic(help_welist_server), 9, 2)
|
||||||
@@ -709,17 +811,48 @@ class Plugin(BalPlugin):
|
|||||||
grid.addWidget(_hide_if_basic(reset_btn_9), 9, 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(_(
|
||||||
"Command used to open .ics calendar files.\n"
|
"Command used to open .ics calendar files.\n"
|
||||||
"Leave empty to use the system default (xdg-open/open/start).\n"
|
"Leave empty to use the system default (xdg-open/open/start).\n"
|
||||||
"Only used in ADVANCED mode."
|
"Only used in ADVANCED mode."
|
||||||
)
|
))
|
||||||
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
grid.addWidget(_hide_if_basic(lbl_calendar_app), 10, 0)
|
||||||
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
grid.addWidget(_hide_if_basic(edit_calendar_app), 10, 1)
|
||||||
grid.addWidget(_hide_if_basic(help_calendar_app), 10, 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), 10, 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), 11, 0)
|
||||||
|
grid.addWidget(_hide_if_basic(heir_save_history), 11, 1)
|
||||||
|
grid.addWidget(_hide_if_basic(help_save_history), 11, 2)
|
||||||
|
reset_btn_11 = _make_reset_btn(self.SAVE_HISTORY, heir_save_history, "check")
|
||||||
|
grid.addWidget(_hide_if_basic(reset_btn_11), 11, 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), 12, 0)
|
||||||
|
grid.addWidget(_hide_if_basic(edit_history_label), 12, 1)
|
||||||
|
grid.addWidget(_hide_if_basic(help_history_label), 12, 2)
|
||||||
|
reset_btn_12 = _make_reset_btn(self.HISTORY_LABEL, edit_history_label, "line")
|
||||||
|
grid.addWidget(_hide_if_basic(reset_btn_12), 12, 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
|
||||||
# added to the grid. The old code did the opposite - it added them
|
# added to the grid. The old code did the opposite - it added them
|
||||||
@@ -727,15 +860,57 @@ class Plugin(BalPlugin):
|
|||||||
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
# the Windows relayout flicker. Do NOT reintroduce a post-hoc
|
||||||
# setVisible() loop here.
|
# setVisible() loop here.
|
||||||
|
|
||||||
grid.addWidget(heir_repush, 11, 0)
|
grid.addWidget(heir_repush, 13, 0)
|
||||||
grid.addWidget(
|
grid.addWidget(
|
||||||
HelpButton(
|
HelpButton(
|
||||||
"Broadcast all transactions to willexecutors including those already pushed"
|
_("Broadcast all transactions to willexecutors including those already pushed")
|
||||||
),
|
),
|
||||||
11,
|
13,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# "Rebuild will on wallet close" row (always visible, BASIC + ADVANCED).
|
||||||
|
# Placed below the rebroadcast button so the existing rows keep their
|
||||||
|
# numbers.
|
||||||
|
lbl_rebuild_on_close = QLabel(_("Rebuild will on wallet close"))
|
||||||
|
help_rebuild_on_close = HelpButton(_(
|
||||||
|
"Run the 'Build your will' wizard every time the wallet is closed "
|
||||||
|
"or Electrum is quit, so the will is rebuilt and re-validated.\n"
|
||||||
|
"When disabled, the will is only rebuilt when you press Check or "
|
||||||
|
"Prepare. The last built state is still saved to the wallet."
|
||||||
|
))
|
||||||
|
grid.addWidget(lbl_rebuild_on_close, 14, 0)
|
||||||
|
grid.addWidget(heir_rebuild_on_close, 14, 1)
|
||||||
|
grid.addWidget(help_rebuild_on_close, 14, 2)
|
||||||
|
reset_btn_rebuild_on_close = _make_reset_btn(
|
||||||
|
self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"
|
||||||
|
)
|
||||||
|
grid.addWidget(reset_btn_rebuild_on_close, 14, 3)
|
||||||
|
|
||||||
|
# "Rebuild automatically on new transactions" row (always visible,
|
||||||
|
# BASIC + ADVANCED), right below the "Rebuild will on wallet close"
|
||||||
|
# row.
|
||||||
|
lbl_auto_rebuild = QLabel(_("Rebuild automatically on new transactions"))
|
||||||
|
help_auto_rebuild = HelpButton(_(
|
||||||
|
"When a new transaction arrives for the wallet, automatically "
|
||||||
|
"rebuild the will the same way the wizard does at wallet close: "
|
||||||
|
"the delivery date is anticipated by one day so the new will "
|
||||||
|
"replaces the previous one, and the rebuilt transactions are "
|
||||||
|
"signed and sent to their will-executors.\n"
|
||||||
|
"An on-chain invalidation transaction is only built when the "
|
||||||
|
"anticipated delivery date would fall before the Check Alive "
|
||||||
|
"threshold, or when the threshold is already in the past.\n"
|
||||||
|
"When disabled (default), the will is only rebuilt on Check / "
|
||||||
|
"Prepare / wallet close."
|
||||||
|
))
|
||||||
|
grid.addWidget(lbl_auto_rebuild, 15, 0)
|
||||||
|
grid.addWidget(heir_auto_rebuild, 15, 1)
|
||||||
|
grid.addWidget(help_auto_rebuild, 15, 2)
|
||||||
|
reset_btn_auto_rebuild = _make_reset_btn(
|
||||||
|
self.AUTO_REBUILD, heir_auto_rebuild, "check"
|
||||||
|
)
|
||||||
|
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
||||||
|
|
||||||
# ----------------------------------------------------------------- #
|
# ----------------------------------------------------------------- #
|
||||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||||
# their factory defaults. It only resets the settings exposed by THIS #
|
# their factory defaults. It only resets the settings exposed by THIS #
|
||||||
@@ -760,11 +935,15 @@ class Plugin(BalPlugin):
|
|||||||
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
(self.AUTO_SIGN, heir_auto_sign, "check"),
|
||||||
(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.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"),
|
||||||
|
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||||
|
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||||
]
|
]
|
||||||
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.
|
||||||
@@ -777,14 +956,18 @@ class Plugin(BalPlugin):
|
|||||||
elif kind == "spin":
|
elif kind == "spin":
|
||||||
widget.setValue(int(cfg.default))
|
widget.setValue(int(cfg.default))
|
||||||
elif kind == "line":
|
elif kind == "line":
|
||||||
widget.setText(cfg.default)
|
widget.setText(cfg.localized_default())
|
||||||
elif kind == "text":
|
elif kind == "text":
|
||||||
widget.setPlainText(cfg.default)
|
widget.setPlainText(cfg.localized_default())
|
||||||
elif kind == "user_type":
|
elif kind == "user_type":
|
||||||
# Default is "basic" -> combo index 0; "advanced" -> index 1.
|
# Default is "basic" -> combo index 0; "advanced" -> index 1.
|
||||||
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.
|
||||||
@@ -810,7 +993,7 @@ class Plugin(BalPlugin):
|
|||||||
bottom_row = QHBoxLayout()
|
bottom_row = QHBoxLayout()
|
||||||
bottom_row.addWidget(btn_reset)
|
bottom_row.addWidget(btn_reset)
|
||||||
bottom_row.addStretch(1)
|
bottom_row.addStretch(1)
|
||||||
bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
|
bottom_row.addWidget(QLabel("<b>{}</b>".format(_("Support:"))))
|
||||||
bottom_row.addWidget(lbl_support)
|
bottom_row.addWidget(lbl_support)
|
||||||
|
|
||||||
# Outer layout: warning (top) -> settings grid -> bottom button row.
|
# Outer layout: warning (top) -> settings grid -> bottom button row.
|
||||||
@@ -862,6 +1045,11 @@ class Plugin(BalPlugin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def get_window_title(self, title):
|
def get_window_title(self, title):
|
||||||
return _("BAL - ") + _(title)
|
"""Return the window title "BAL - <title>".
|
||||||
|
|
||||||
|
``title`` must already be translated by the caller: a variable passed
|
||||||
|
to _() cannot be extracted into the catalog.
|
||||||
|
"""
|
||||||
|
return _("BAL - {}").format(title)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,31 @@ def status_color(will_item) -> str:
|
|||||||
return "#e83845" # red - failed to push to will-executor
|
return "#e83845" # red - failed to push to will-executor
|
||||||
elif will_item.get_status("PUSHED"):
|
elif will_item.get_status("PUSHED"):
|
||||||
return "#73f3c8" # teal - pushed to will-executor
|
return "#73f3c8" # teal - pushed to will-executor
|
||||||
|
elif will_item.get_status("PARTIALLY_SIGNED"):
|
||||||
|
return "#ffb347" # amber - some signatures present, more needed
|
||||||
elif will_item.get_status("COMPLETE"):
|
elif will_item.get_status("COMPLETE"):
|
||||||
return "#2bc8ed" # blue - signed
|
return "#2bc8ed" # blue - signed
|
||||||
else:
|
else:
|
||||||
return _DEFAULT_COLOR
|
return _DEFAULT_COLOR
|
||||||
|
|
||||||
|
|
||||||
|
def signature_suffix(will_item) -> str:
|
||||||
|
"""Return the ``" (added/required)"`` suffix for a non-signed will item.
|
||||||
|
|
||||||
|
Used by the transaction list and the detail view to show how many of the
|
||||||
|
required signatures have already been added, e.g. ``"(1/2)"`` for a 2-of-3
|
||||||
|
transaction carrying one signature. Returns ``""`` for signed transactions
|
||||||
|
or when the required count is unknown (no descriptor available yet).
|
||||||
|
"""
|
||||||
|
if will_item.get_status("COMPLETE"):
|
||||||
|
return ""
|
||||||
|
required = int(getattr(will_item, "sigs_required", 0) or 0)
|
||||||
|
added = int(getattr(will_item, "sigs_have", 0) or 0)
|
||||||
|
if not required:
|
||||||
|
return ""
|
||||||
|
return " ({}/{})".format(added, required)
|
||||||
|
|
||||||
|
|
||||||
def server_status_text(will_item) -> str:
|
def server_status_text(will_item) -> str:
|
||||||
"""Return a short, human-readable label describing the state of a will
|
"""Return a short, human-readable label describing the state of a will
|
||||||
item on the will-executor servers (the online inheritance backup).
|
item on the will-executor servers (the online inheritance backup).
|
||||||
@@ -68,7 +87,7 @@ def server_status_text(will_item) -> str:
|
|||||||
the user always knows whether each inheritance transaction is actually
|
the user always knows whether each inheritance transaction is actually
|
||||||
stored on the will-executor servers, regardless of the row colour.
|
stored on the will-executor servers, regardless of the row colour.
|
||||||
"""
|
"""
|
||||||
from electrum.i18n import _
|
from ...i18n import _
|
||||||
|
|
||||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||||
return _("Not on server")
|
return _("Not on server")
|
||||||
@@ -86,7 +105,7 @@ def server_status_text(will_item) -> str:
|
|||||||
def server_status_tooltip(will_item) -> str:
|
def server_status_tooltip(will_item) -> str:
|
||||||
"""Return a detailed tooltip for the "Server" column, including the
|
"""Return a detailed tooltip for the "Server" column, including the
|
||||||
will-executor URL (if any) and the current server state."""
|
will-executor URL (if any) and the current server state."""
|
||||||
from electrum.i18n import _
|
from ...i18n import _
|
||||||
|
|
||||||
url = None
|
url = None
|
||||||
we = getattr(will_item, "we", None)
|
we = getattr(will_item, "we", None)
|
||||||
|
|||||||
@@ -18,89 +18,70 @@ Contents:
|
|||||||
* WillWidget - single will-tx box
|
* WillWidget - single will-tx box
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .common import *
|
from typing import TYPE_CHECKING
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
|
||||||
|
from ...core.checkalive import resolve_relative_check_alive_date
|
||||||
|
from ...core.heirs import get_op_return_hex, is_op_return_address
|
||||||
|
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 .calendar import BalCalendar, BalCalendarButton
|
||||||
|
from .common import (
|
||||||
|
DECIMAL_POINT,
|
||||||
|
N_,
|
||||||
|
NLOCKTIME_BLOCKHEIGHT_MAX,
|
||||||
|
NLOCKTIME_MAX,
|
||||||
|
Any,
|
||||||
|
BalTimestamp,
|
||||||
|
BTCAmountEdit,
|
||||||
|
ColorScheme,
|
||||||
|
Decimal,
|
||||||
|
HelpButton,
|
||||||
|
Optional,
|
||||||
|
QAbstractSpinBox,
|
||||||
|
QCheckBox,
|
||||||
|
QColor,
|
||||||
|
QComboBox,
|
||||||
|
QDateTime,
|
||||||
|
QDateTimeEdit,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPainter,
|
||||||
|
QPalette,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStyle,
|
||||||
|
QStyleOptionFrame,
|
||||||
|
Qt,
|
||||||
|
QTextEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
Union,
|
||||||
|
Util,
|
||||||
|
Will,
|
||||||
|
_,
|
||||||
|
_logger,
|
||||||
|
char_width_in_lineedit,
|
||||||
|
datetime,
|
||||||
|
format_status_history,
|
||||||
|
getSaveFileName,
|
||||||
|
log_error,
|
||||||
|
os,
|
||||||
|
partial,
|
||||||
|
pyqtSignal,
|
||||||
|
read_QIcon_from_bytes,
|
||||||
|
signature_suffix,
|
||||||
|
status_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
def compute_reminder_offsets(days, count):
|
from .window import BalWindow
|
||||||
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
|
||||||
|
|
||||||
Group D / D1. The reminders are spread uniformly across the check-alive
|
|
||||||
period and always fall *before* the delivery deadline, i.e. every returned
|
|
||||||
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
* ``count`` is the requested number of reminders (the settings dialog
|
|
||||||
caps it at 5, default 3).
|
|
||||||
* at most ONE reminder per available day: the effective number is
|
|
||||||
``min(count, days)``;
|
|
||||||
* with ``days`` available days, offsets are chosen as evenly spaced
|
|
||||||
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
|
||||||
= the first day of the period), de-duplicated and returned sorted
|
|
||||||
descending (earliest reminder first).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days: number of whole days between check-alive and the deadline.
|
|
||||||
count: requested number of reminders.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
|
||||||
``days=30, count=3``. Empty if there is no room for any reminder.
|
|
||||||
"""
|
|
||||||
# No room for any reminder (deadline today or already passed).
|
|
||||||
if days < 1 or count < 1:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Never more reminders than available days (one per day at most).
|
|
||||||
effective = min(int(count), int(days))
|
|
||||||
|
|
||||||
# A single reminder: put it one day before the deadline.
|
|
||||||
if effective == 1:
|
|
||||||
return [1]
|
|
||||||
|
|
||||||
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
|
||||||
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
|
||||||
# This places the first reminder at the start of the period (offset ~days)
|
|
||||||
# and the last one one day before the deadline (offset 1).
|
|
||||||
offsets = set()
|
|
||||||
for i in range(effective):
|
|
||||||
frac = i / (effective - 1) # 0.0 .. 1.0
|
|
||||||
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
|
||||||
offset = round(days - frac * (days - 1))
|
|
||||||
offset = max(1, min(days, offset))
|
|
||||||
offsets.add(offset)
|
|
||||||
|
|
||||||
# Sorted descending: earliest reminder (largest offset) first.
|
|
||||||
return sorted(offsets, reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
|
|
||||||
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
|
|
||||||
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
|
|
||||||
# 1 day before the inheritance delivery date.
|
|
||||||
BASIC_REMINDER_OFFSETS = (30, 10, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def basic_reminder_offsets(days_to_deadline):
|
|
||||||
"""Return the BASIC-mode reminder offsets that still fall in the future.
|
|
||||||
|
|
||||||
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
|
|
||||||
1 day before the delivery date). Any offset that would land in the past is
|
|
||||||
dropped, because a reminder before "today" is useless: if the delivery date
|
|
||||||
is only ``days_to_deadline`` days away, only the offsets that are ``<=
|
|
||||||
days_to_deadline`` are kept.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
days_to_deadline: whole days from now until the delivery date.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A list of integer day-offsets (each ``>= 1``), sorted as in
|
|
||||||
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
|
|
||||||
when the delivery date is less than one day away.
|
|
||||||
"""
|
|
||||||
horizon = max(int(days_to_deadline), 0)
|
|
||||||
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
|
|
||||||
|
|
||||||
|
|
||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
@@ -214,39 +195,12 @@ class BalTxFeesWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _LockTimeEditor:
|
class _LockTimeEditor(LockTimeEditor):
|
||||||
min_allowed_value = NLOCKTIME_MIN
|
"""Qt-side locktime editor base.
|
||||||
max_allowed_value = NLOCKTIME_MAX
|
|
||||||
alarm = None
|
|
||||||
|
|
||||||
def get_value(self) -> Optional[int]:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def set_value(self, x: Any, force=True) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_acceptable_locktime(cls, x: Any) -> bool:
|
|
||||||
if not x: # e.g. empty string
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
x = int(x)
|
|
||||||
except Exception as _e:
|
|
||||||
return False
|
|
||||||
return cls.min_allowed_value <= x <= cls.max_allowed_value
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_max_allowed_timestamp() -> int:
|
|
||||||
ts = NLOCKTIME_MAX
|
|
||||||
# Test if this value is within the valid timestamp limits (which is platform-dependent).
|
|
||||||
# see #6170
|
|
||||||
try:
|
|
||||||
datetime.fromtimestamp(ts)
|
|
||||||
except (OSError, OverflowError):
|
|
||||||
ts = 2**31 - 1 # INT32_MAX
|
|
||||||
datetime.fromtimestamp(ts) # test if raises
|
|
||||||
return ts
|
|
||||||
|
|
||||||
|
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
|
||||||
|
this is just the mixin that lets Qt widgets reuse them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||||
@@ -256,14 +210,18 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
|||||||
current_index = None
|
current_index = None
|
||||||
default_value = None
|
default_value = None
|
||||||
|
|
||||||
help_text = (
|
help_text = N_(
|
||||||
"if you choose Raw, you can insert various options based on suffix:\n"
|
"if you choose Raw, you can insert various options based on suffix:\n"
|
||||||
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
|
" - d: number of days after current day(ex: 1d means tomorrow)\n"
|
||||||
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
" - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||||
)
|
)
|
||||||
label_text = None
|
label_text = None
|
||||||
tooltip_text = None
|
tooltip_text = None
|
||||||
base_field = None
|
base_field = None
|
||||||
|
# A leading "-" is accepted by the RAW editor only for the Check Alive
|
||||||
|
# field (its sign selects the anchor date); the delivery field never
|
||||||
|
# accepts a sign. Subclasses override this.
|
||||||
|
allow_negative_raw = False
|
||||||
|
|
||||||
def __init__(self, bal_window, parent, default_locktime=None):
|
def __init__(self, bal_window, parent, default_locktime=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -274,7 +232,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
|||||||
hbox.setContentsMargins(0, 0, 0, 0)
|
hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
hbox.setSpacing(0)
|
hbox.setSpacing(0)
|
||||||
self.setMinimumWidth(40 * char_width_in_lineedit())
|
self.setMinimumWidth(40 * char_width_in_lineedit())
|
||||||
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
|
self.locktime_raw_e = TimeRawEditWidget(
|
||||||
|
self, time_edit=self, allow_negative=self.allow_negative_raw
|
||||||
|
)
|
||||||
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
|
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
|
||||||
self.editors = [self.locktime_raw_e, self.locktime_date_e]
|
self.editors = [self.locktime_raw_e, self.locktime_date_e]
|
||||||
self.combo = QComboBox()
|
self.combo = QComboBox()
|
||||||
@@ -325,7 +285,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
#hbox.addWidget(QLabel(self.label_text))
|
#hbox.addWidget(QLabel(self.label_text))
|
||||||
help_button=HelpButton(self.help_text)
|
# help_text and tooltip_text are class attributes marked with N_():
|
||||||
|
# translate them here, when shown.
|
||||||
|
help_button=HelpButton(_(self.help_text))
|
||||||
help_button.setText(self.label_text)
|
help_button.setText(self.label_text)
|
||||||
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
||||||
# user hovers the icon, so the emoji button is self-explanatory.
|
# user hovers the icon, so the emoji button is self-explanatory.
|
||||||
@@ -527,9 +489,9 @@ class TimeRawEditWidget(QWidget):
|
|||||||
def is_acceptable_locktime(self, value):
|
def is_acceptable_locktime(self, value):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __init__(self, parent, time_edit=None):
|
def __init__(self, parent, time_edit=None, allow_negative=False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.editor = LockTimeRawEdit(parent, time_edit)
|
self.editor = LockTimeRawEdit(parent, time_edit, allow_negative=allow_negative)
|
||||||
self.label = QLabel("")
|
self.label = QLabel("")
|
||||||
# Group C / C3: this trailing label shows the ABSOLUTE date computed
|
# Group C / C3: this trailing label shows the ABSOLUTE date computed
|
||||||
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
|
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
|
||||||
@@ -555,7 +517,7 @@ class TimeRawEditWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||||
def __init__(self, parent=None, time_edit=None):
|
def __init__(self, parent=None, time_edit=None, allow_negative=False):
|
||||||
QLineEdit.__init__(self, parent)
|
QLineEdit.__init__(self, parent)
|
||||||
# Group C / C3: narrow the RAW input to roughly a third of its former
|
# Group C / C3: narrow the RAW input to roughly a third of its former
|
||||||
# width. The accepted values are short relative durations such as "30d"
|
# width. The accepted values are short relative durations such as "30d"
|
||||||
@@ -565,53 +527,24 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
|||||||
self.textChanged.connect(self.numbify)
|
self.textChanged.connect(self.numbify)
|
||||||
self.isdays = False
|
self.isdays = False
|
||||||
self.isyears = False
|
self.isyears = False
|
||||||
|
# The Check Alive field accepts a leading "-" (the sign selects the
|
||||||
|
# anchor date); the delivery field does not.
|
||||||
|
self.allow_negative = allow_negative
|
||||||
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
|
# (and, for the Check Alive field, a single leading "-"). The
|
||||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
# block-height suffix ("b") was removed (A1): locktimes are always UNIX
|
||||||
|
# timestamps now, so block-relative input is no longer allowed.
|
||||||
|
# The sanitisation itself lives in bal.core.input_rules.
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
chars = "0123456789dy"
|
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
s, isdays, isyears, pos = normalize_locktime_raw_text(
|
||||||
s = "".join([i for i in text if i in chars])
|
text, pos, allow_negative=self.allow_negative
|
||||||
self.isdays = False
|
)
|
||||||
self.isyears = False
|
self.isdays = isdays
|
||||||
|
self.isyears = isyears
|
||||||
pos, s = self.checkbdy(s, pos, "d")
|
|
||||||
pos, s = self.checkbdy(s, pos, "y")
|
|
||||||
|
|
||||||
if "d" in s:
|
|
||||||
self.isdays = True
|
|
||||||
if "y" in s:
|
|
||||||
self.isyears = True
|
|
||||||
|
|
||||||
if self.isdays:
|
|
||||||
s = self.replace_str(s) + "d"
|
|
||||||
if self.isyears:
|
|
||||||
s = self.replace_str(s) + "y"
|
|
||||||
self.blockSignals(True)
|
self.blockSignals(True)
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.blockSignals(False)
|
self.blockSignals(False)
|
||||||
@@ -690,21 +623,26 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
x = int(x)
|
x = int(x)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
x = QDateTime.currentDateTime().timestamp()
|
x = QDateTime.currentDateTime().timestamp()
|
||||||
finally:
|
finally:
|
||||||
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
|
||||||
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
|
||||||
_dt = BalTimestamp._safe_fromtimestamp(x)
|
_dt = BalTimestamp._safe_fromtimestamp(x)
|
||||||
#if self.alarm != dt:
|
|
||||||
self.setDateTime(_dt)
|
|
||||||
self.alarm = _dt
|
self.alarm = _dt
|
||||||
|
# Store the LOCAL wall-clock time, not the aware-UTC datetime:
|
||||||
|
# QDateTimeEdit keeps the given wall time with a LocalTime spec, so
|
||||||
|
# an aware-UTC datetime would make get_value() read back a timezone-
|
||||||
|
# shifted epoch. That broke the set_value -> get_value roundtrip and
|
||||||
|
# kept the valueEdited -> update_setting_widgets -> set_value cycle
|
||||||
|
# firing forever (infinite RecursionError on wizard "Next").
|
||||||
|
self.setDateTime(_dt.astimezone().replace(tzinfo=None))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ThresholdTimeWidget(BalTimeEditWidget):
|
class ThresholdTimeWidget(BalTimeEditWidget):
|
||||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||||
help_text = (
|
help_text = N_(
|
||||||
"<b>CHECK ALIVE</b><br><br>"
|
"<b>CHECK ALIVE</b><br><br>"
|
||||||
"In DATA mode:<br>"
|
"In DATA mode:<br>"
|
||||||
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
||||||
@@ -718,13 +656,20 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
|||||||
"if you choose Raw, you can insert various options based on suffix:<br>"
|
"if you choose Raw, you can insert various options based on suffix:<br>"
|
||||||
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
||||||
" - y: number of years after current day(ex: 1y means one year from today)<br>"
|
" - y: number of years after current day(ex: 1y means one year from today)<br>"
|
||||||
|
"The sign selects the reference date:<br>"
|
||||||
|
" - a NEGATIVE value (ex: -30d) counts backwards from the "
|
||||||
|
"<b>delivery</b> date (30 days before the delivery);<br>"
|
||||||
|
" - a POSITIVE value (ex: 30d) counts forward from the "
|
||||||
|
"<b>inheritance creation</b> date (30 days after the will was created).<br>"
|
||||||
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
||||||
"<b>Local time</b> (not UTC time).<br>"
|
"<b>Local time</b> (not UTC time).<br>"
|
||||||
)
|
)
|
||||||
label_text = "🚨"
|
label_text = "🚨"
|
||||||
#label_text = "Check Alive"
|
#label_text = "Check Alive"
|
||||||
tooltip_text = "Check Alive"
|
tooltip_text = N_("Check Alive")
|
||||||
base_field = "threshold"
|
base_field = "threshold"
|
||||||
|
# The Check Alive field accepts a signed value; the delivery field does not.
|
||||||
|
allow_negative_raw = True
|
||||||
|
|
||||||
def __init__(self, bal_window, parent, init_value=None):
|
def __init__(self, bal_window, parent, init_value=None):
|
||||||
if init_value is None:
|
if init_value is None:
|
||||||
@@ -738,7 +683,7 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
|||||||
|
|
||||||
class LockTimeWidget(BalTimeEditWidget):
|
class LockTimeWidget(BalTimeEditWidget):
|
||||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||||
help_text = (
|
help_text = N_(
|
||||||
"<b>DELIVERY TIME</b><br><br>"
|
"<b>DELIVERY TIME</b><br><br>"
|
||||||
"Set Locktime for transactions.<br>"
|
"Set Locktime for transactions.<br>"
|
||||||
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
||||||
@@ -756,7 +701,7 @@ class LockTimeWidget(BalTimeEditWidget):
|
|||||||
#label_text = "Locktime"
|
#label_text = "Locktime"
|
||||||
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
||||||
# icon tooltip ("..., click for more information") so the two are consistent.
|
# icon tooltip ("..., click for more information") so the two are consistent.
|
||||||
tooltip_text = "Delivery Time, click for more information"
|
tooltip_text = N_("Delivery Time, click for more information")
|
||||||
base_field = "locktime"
|
base_field = "locktime"
|
||||||
|
|
||||||
def __init__(self, bal_window, parent, init_value=None):
|
def __init__(self, bal_window, parent, init_value=None):
|
||||||
@@ -1068,11 +1013,12 @@ class WillSettingsWidget(QWidget):
|
|||||||
sees several distinct appointments in their calendar.
|
sees several distinct appointments in their calendar.
|
||||||
|
|
||||||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||||||
capped at 5 by the settings dialog). Their dates are computed with
|
capped at 5 by the settings dialog). Their dates are computed by
|
||||||
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
|
||||||
check-alive period and the LAST event always falls one day before the
|
across the check-alive period; the LAST event always falls one day
|
||||||
delivery deadline (locktime). If the period is shorter than the
|
before the delivery deadline / locktime). If the period is shorter than
|
||||||
requested number of reminders, at most one event per day is produced.
|
the requested number of reminders, at most one event per day is
|
||||||
|
produced.
|
||||||
|
|
||||||
Each event:
|
Each event:
|
||||||
* is placed on ``locktime - offset`` days (its own visible date);
|
* is placed on ``locktime - offset`` days (its own visible date);
|
||||||
@@ -1087,103 +1033,23 @@ class WillSettingsWidget(QWidget):
|
|||||||
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||||||
on the Desktop).
|
on the Desktop).
|
||||||
"""
|
"""
|
||||||
now = BalCalendar.format_time(datetime.now())
|
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
|
||||||
|
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
|
||||||
# locktime = delivery deadline. It is exposed by the date widget as
|
# reminder falls in the future it returns None (ToDo #2) and the user is
|
||||||
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
|
# warned instead of getting an empty-looking file.
|
||||||
# transaction locktime, so the calendar uses the correct delivery date.
|
ics_content = self._ics_provider()
|
||||||
locktime = self.widgets["locktime"].alarm
|
if not ics_content:
|
||||||
|
self.bal_window.show_warning(
|
||||||
# BASIC vs ADVANCED reminder strategy.
|
_(
|
||||||
#
|
"No reminders were saved: the delivery date is too "
|
||||||
# In ADVANCED mode the reminders are spread uniformly across the
|
"close (or already passed)"
|
||||||
# check-alive (threshold) period, ending one day before the deadline.
|
)
|
||||||
#
|
|
||||||
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
|
|
||||||
# user (it stays at an arbitrary default), so spreading reminders over it
|
|
||||||
# is meaningless. The owner asked that, in BASIC, the calendar simply
|
|
||||||
# saves the inheritance delivery date with three fixed reminders: 30 days
|
|
||||||
# before, 10 days before and 1 day before. We also drop any fixed offset
|
|
||||||
# that would fall in the past (a reminder before "today" is useless), so
|
|
||||||
# a short-dated will still gets the reminders that are still in the
|
|
||||||
# future.
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
|
||||||
# Whole days from now until the delivery date. Fixed offsets (30, 10,
|
|
||||||
# 1 day before) are applied by basic_reminder_offsets, which also
|
|
||||||
# drops any offset that would fall in the past.
|
|
||||||
days_to_deadline = (locktime - datetime.now()).days
|
|
||||||
offsets = basic_reminder_offsets(days_to_deadline)
|
|
||||||
else:
|
|
||||||
# ADVANCED: spread reminders over the check-alive period as before.
|
|
||||||
threshold = self.widgets["threshold"].alarm
|
|
||||||
# Whole days available between check-alive and the deadline.
|
|
||||||
days = (locktime - threshold).days
|
|
||||||
# How many reminders the user asked for (default 3 if unreadable).
|
|
||||||
try:
|
|
||||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
|
||||||
except Exception:
|
|
||||||
count = 3
|
|
||||||
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
|
|
||||||
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
|
|
||||||
offsets = compute_reminder_offsets(days, count)
|
|
||||||
|
|
||||||
# Per-event heir details and the shared description/summary templates.
|
|
||||||
heirs_details = "\r\n".join(
|
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
|
|
||||||
for heir in self.bal_window.heirs
|
|
||||||
)
|
|
||||||
# BASIC mode: use factory defaults (the hidden settings are ignored).
|
|
||||||
# ADVANCED mode: use the user-configured values.
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
|
||||||
else:
|
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
|
||||||
event_description = BalCalendar.ical_escape(
|
|
||||||
f"{raw_description}"
|
|
||||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
|
||||||
.replace("$heirs_complete", heirs_details)
|
|
||||||
)
|
|
||||||
summary_base = (
|
|
||||||
f"{raw_summary}"
|
|
||||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
|
||||||
)
|
|
||||||
|
|
||||||
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([
|
return
|
||||||
"BEGIN:VEVENT",
|
|
||||||
# Offset in the UID keeps each event unique (no merging).
|
|
||||||
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
|
||||||
f"DTSTAMP:{now}",
|
|
||||||
f"DTSTART:{event_dt}",
|
|
||||||
f"DTEND:{event_dt}",
|
|
||||||
f"SUMMARY:{summary}",
|
|
||||||
f"DESCRIPTION:{event_description}",
|
|
||||||
"END:VEVENT",
|
|
||||||
])
|
|
||||||
|
|
||||||
lines.append("END:VCALENDAR")
|
|
||||||
|
|
||||||
lines = [s.rstrip("\r\n") for s in lines]
|
|
||||||
ics_content = "\r\n".join(lines) + "\r\n"
|
|
||||||
# Keep the generated .ics in a temp file; it is copied to the path the
|
# Keep the generated .ics in a temp file; it is copied to the path the
|
||||||
# user picks below.
|
# user picks below.
|
||||||
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
self.temp_path = write_temp_ics(ics_content)
|
||||||
|
|
||||||
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||||||
# plugin no longer tries to open it with a calendar app). The save
|
# plugin no longer tries to open it with a calendar app). The save
|
||||||
@@ -1236,26 +1102,34 @@ class WillSettingsWidget(QWidget):
|
|||||||
def _ics_provider(self):
|
def _ics_provider(self):
|
||||||
"""Return the .ics content for the current locktime/threshold values.
|
"""Return the .ics content for the current locktime/threshold values.
|
||||||
|
|
||||||
Used by :class:`BalCalendarButton` as its content provider.
|
Used by :class:`BalCalendarButton` as its content provider. The whole
|
||||||
|
document (reminder offsets, escaping, folding, VEVENT layout) is built
|
||||||
|
by the pure :func:`bal.core.reminders.build_ics_reminders`.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
locktime = self.widgets["locktime"].alarm
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||||
days_to_deadline = (locktime - datetime.now()).days
|
if basic_mode:
|
||||||
offsets = basic_reminder_offsets(days_to_deadline)
|
# BASIC mode: use factory defaults (the hidden settings are
|
||||||
|
# ignored) and the fixed 30/10/1 offsets. The defaults are in
|
||||||
|
# the GUI language (see BalConfig).
|
||||||
|
raw_description = (
|
||||||
|
self.bal_window.bal_plugin.EVENT_DESCRIPTION.localized_default()
|
||||||
|
)
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.localized_default()
|
||||||
|
threshold = None
|
||||||
|
num_reminders = 3
|
||||||
else:
|
else:
|
||||||
|
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||||||
|
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||||||
threshold = self.widgets["threshold"].alarm
|
threshold = self.widgets["threshold"].alarm
|
||||||
days = (locktime - threshold).days
|
|
||||||
try:
|
try:
|
||||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
num_reminders = int(
|
||||||
|
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
count = 3
|
num_reminders = 3
|
||||||
offsets = compute_reminder_offsets(days, count)
|
|
||||||
|
|
||||||
now = BalCalendar.format_time(datetime.now())
|
|
||||||
|
|
||||||
heirs_details = "\r\n".join(
|
heirs_details = "\r\n".join(
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||||
@@ -1263,68 +1137,41 @@ class WillSettingsWidget(QWidget):
|
|||||||
for heir in self.bal_window.heirs
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
return build_ics_reminders(
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
locktime=locktime,
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
basic_mode=basic_mode,
|
||||||
else:
|
description=raw_description,
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
summary=raw_summary,
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
wallet_name=str(self.bal_window.wallet),
|
||||||
|
heirs_details=heirs_details,
|
||||||
event_description = BalCalendar.ical_escape(
|
version=self.bal_window.bal_plugin.version,
|
||||||
f"{raw_description}"
|
num_reminders=num_reminders,
|
||||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
threshold=threshold,
|
||||||
.replace("$heirs_complete", heirs_details)
|
reminder_suffix=_("(reminder {idx}/{total})"),
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
def on_locktime_change(self):
|
def on_locktime_change(self):
|
||||||
locktime = self.widgets["locktime"].get_value()
|
locktime = self.widgets["locktime"].get_value()
|
||||||
threshold = self.widgets["threshold"].get_value()
|
threshold_raw = self.widgets["threshold"].get_value()
|
||||||
locktime = BalTimestamp(locktime)
|
locktime = BalTimestamp(locktime)
|
||||||
threshold = BalTimestamp(threshold)
|
|
||||||
|
|
||||||
min_locktime = min(
|
min_locktime = min(
|
||||||
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
|
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
|
||||||
locktime.to_timestamp(),
|
locktime.to_timestamp(),
|
||||||
)
|
)
|
||||||
# Resolved Check Alive date. For relative values this counts BACKWARDS
|
# Resolved Check Alive date. The SIGN of the RAW value selects the
|
||||||
# from the delivery time (reverse=True): "90d" means "90 days BEFORE the
|
# anchor: negative counts backwards from the delivery, positive counts
|
||||||
# delivery", not "90 days from now".
|
# forward from the inheritance creation date. The rule lives in
|
||||||
td = threshold.to_date(min_locktime, True)
|
# ``bal.core.checkalive`` so this display matches every validity check.
|
||||||
|
resolved = resolve_relative_check_alive_date(
|
||||||
|
threshold_raw,
|
||||||
|
delivery_date=min_locktime,
|
||||||
|
creation_date=Will.get_creation_time(self.bal_window.willitems),
|
||||||
|
)
|
||||||
|
td = BalTimestamp._safe_fromtimestamp(resolved)
|
||||||
self.widgets["threshold"].alarm=td
|
self.widgets["threshold"].alarm=td
|
||||||
self.bal_window.will_settings["real_threshold"]=td.timestamp()
|
self.bal_window.will_settings["real_threshold"]=td.timestamp()
|
||||||
try:
|
try:
|
||||||
@@ -1349,40 +1196,20 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
|
# The text sanitisation lives in bal.core.input_rules.
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
if text == "!":
|
if text == "!":
|
||||||
self.shortcut.emit()
|
self.shortcut.emit()
|
||||||
return
|
return
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
chars = "0123456789%"
|
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
|
||||||
chars += DECIMAL_POINT
|
|
||||||
|
|
||||||
s = "".join([i for i in text if i in chars])
|
|
||||||
|
|
||||||
if "%" in s:
|
|
||||||
self.is_perc = True
|
|
||||||
s = s.replace("%", "")
|
|
||||||
else:
|
|
||||||
self.is_perc = False
|
|
||||||
|
|
||||||
if DECIMAL_POINT in s:
|
|
||||||
p = s.find(DECIMAL_POINT)
|
|
||||||
s = s.replace(DECIMAL_POINT, "")
|
|
||||||
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
|
|
||||||
if self.is_perc:
|
|
||||||
s += "%"
|
|
||||||
|
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.setModified(self.hasFocus())
|
self.setModified(self.hasFocus())
|
||||||
self.setCursorPosition(pos)
|
self.setCursorPosition(pos)
|
||||||
|
|
||||||
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
||||||
try:
|
return parse_perc_amount(text, DECIMAL_POINT)
|
||||||
text = text.replace(DECIMAL_POINT, ".")
|
|
||||||
text = text.replace("%", "")
|
|
||||||
return (Decimal)(text)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_text_from_amount(self, amount):
|
def _get_text_from_amount(self, amount):
|
||||||
out = super()._get_text_from_amount(amount)
|
out = super()._get_text_from_amount(amount)
|
||||||
@@ -1395,17 +1222,17 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
if self.base_unit:
|
if self.base_unit:
|
||||||
panel = QStyleOptionFrame()
|
panel = QStyleOptionFrame()
|
||||||
self.initStyleOption(panel)
|
self.initStyleOption(panel)
|
||||||
textRect = self.style().subElementRect(
|
text_rect = self.style().subElementRect(
|
||||||
QStyle.SubElement.SE_LineEditContents, panel, self
|
QStyle.SubElement.SE_LineEditContents, panel, self
|
||||||
)
|
)
|
||||||
textRect.adjust(2, 0, -10, 0)
|
text_rect.adjust(2, 0, -10, 0)
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setPen(ColorScheme.GRAY.as_color())
|
painter.setPen(ColorScheme.GRAY.as_color())
|
||||||
if len(self.text()) == 0:
|
if len(self.text()) == 0:
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
textRect,
|
text_rect,
|
||||||
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
|
||||||
self.base_unit() + " or perc value",
|
_("{} or perc value").format(self.base_unit()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1482,11 +1309,11 @@ class BalSpinBox(QSpinBox):
|
|||||||
|
|
||||||
|
|
||||||
class WillWidget(QWidget):
|
class WillWidget(QWidget):
|
||||||
def __init__(self, father=None, parent=None):
|
def __init__(self, father=None, parent=None, will=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
vlayout = QVBoxLayout()
|
vlayout = QVBoxLayout()
|
||||||
self.setLayout(vlayout)
|
self.setLayout(vlayout)
|
||||||
self.will = parent.bal_window.willitems
|
self.will = will if will is not None else parent.bal_window.willitems
|
||||||
self._bal_parent = parent
|
self._bal_parent = parent
|
||||||
for w in self.will:
|
for w in self.will:
|
||||||
if (
|
if (
|
||||||
@@ -1513,18 +1340,22 @@ class WillWidget(QWidget):
|
|||||||
willpushbutton = QPushButton(w)
|
willpushbutton = QPushButton(w)
|
||||||
|
|
||||||
willpushbutton.clicked.connect(
|
willpushbutton.clicked.connect(
|
||||||
partial(self._bal_parent.bal_window.show_transaction, txid=w)
|
partial(
|
||||||
|
self._bal_parent.bal_window.show_transaction,
|
||||||
|
tx=self.will[w].tx,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
detaillayout.addWidget(willpushbutton)
|
detaillayout.addWidget(willpushbutton)
|
||||||
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
locktime = str(BalTimestamp(self.will[w].tx.locktime))
|
||||||
creation = str(BalTimestamp(self.will[w].time))
|
creation = str(BalTimestamp(self.will[w].time))
|
||||||
|
|
||||||
def qlabel(title, value):
|
def qlabel(title, value):
|
||||||
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
|
# ``title`` is shown as it is: callers pass a translated
|
||||||
return QLabel(label)
|
# label, or data (an heir name, a will-executor URL).
|
||||||
|
return QLabel("<b>{}:</b>\t{}".format(title, value))
|
||||||
|
|
||||||
detaillayout.addWidget(qlabel("Locktime", locktime))
|
detaillayout.addWidget(qlabel(_("Locktime"), locktime))
|
||||||
detaillayout.addWidget(qlabel("Creation Time", creation))
|
detaillayout.addWidget(qlabel(_("Creation Time"), creation))
|
||||||
try:
|
try:
|
||||||
total_fees = (
|
total_fees = (
|
||||||
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
self.will[w].tx.input_value() - self.will[w].tx.output_value()
|
||||||
@@ -1534,23 +1365,46 @@ class WillWidget(QWidget):
|
|||||||
decoded_fees = total_fees
|
decoded_fees = total_fees
|
||||||
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
|
||||||
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
|
||||||
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
|
detaillayout.addWidget(qlabel(_("Transaction fees"), fees_str))
|
||||||
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
|
# The saved status history is English: translate it for display.
|
||||||
|
detaillayout.addWidget(
|
||||||
|
qlabel(
|
||||||
|
_("Status"),
|
||||||
|
format_status_history(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>{}</b>".format(_("Heirs:"))))
|
||||||
for heir in self.will[w].heirs:
|
for heir_name in self.will[w].heirs:
|
||||||
if 'w!ll3x3c"' not in heir:
|
if 'w!ll3x3c"' in heir_name:
|
||||||
decoded_amount = Util.decode_amount(
|
continue
|
||||||
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
|
h = self.will[w].heirs[heir_name]
|
||||||
)
|
decoded_amount = Util.decode_amount(
|
||||||
|
h[3], self._bal_parent.decimal_point
|
||||||
|
)
|
||||||
|
if is_op_return_address(h[0]):
|
||||||
|
data_hex = get_op_return_hex(h[0]) or ""
|
||||||
|
try:
|
||||||
|
decoded = bytes.fromhex(data_hex).decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
decoded = h[0]
|
||||||
|
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
|
||||||
|
else:
|
||||||
detaillayout.addWidget(
|
detaillayout.addWidget(
|
||||||
qlabel(
|
qlabel(
|
||||||
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
|
heir_name,
|
||||||
|
f"{decoded_amount} {self._bal_parent.base_unit_name} "
|
||||||
|
f"[{h[0]}]",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self.will[w].we:
|
if self.will[w].we:
|
||||||
detaillayout.addWidget(QLabel(""))
|
detaillayout.addWidget(QLabel(""))
|
||||||
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
|
detaillayout.addWidget(
|
||||||
|
QLabel("<b>{}</b>".format(_("Willexecutor:")))
|
||||||
|
)
|
||||||
decoded_amount = Util.decode_amount(
|
decoded_amount = Util.decode_amount(
|
||||||
self.will[w].we["base_fee"], self._bal_parent.decimal_point
|
self.will[w].we["base_fee"], self._bal_parent.decimal_point
|
||||||
)
|
)
|
||||||
@@ -1561,6 +1415,10 @@ class WillWidget(QWidget):
|
|||||||
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
f"{decoded_amount} {self._bal_parent.base_unit_name}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if self.will[w].we.get("address"):
|
||||||
|
detaillayout.addWidget(
|
||||||
|
qlabel(_("Address"), self.will[w].we["address"])
|
||||||
|
)
|
||||||
detaillayout.addStretch()
|
detaillayout.addStretch()
|
||||||
pal = QPalette()
|
pal = QPalette()
|
||||||
pal.setColor(
|
pal.setColor(
|
||||||
@@ -1570,6 +1428,6 @@ class WillWidget(QWidget):
|
|||||||
detailw.setPalette(pal)
|
detailw.setPalette(pal)
|
||||||
|
|
||||||
hlayout.addWidget(detailw)
|
hlayout.addWidget(detailw)
|
||||||
hlayout.addWidget(WillWidget(w, parent=parent))
|
hlayout.addWidget(WillWidget(w, parent=parent, will=self.will))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1302
bal/gui/qt/window.py
1302
bal/gui/qt/window.py
File diff suppressed because it is too large
Load Diff
198
bal/i18n.py
Normal file
198
bal/i18n.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
bal.i18n
|
||||||
|
========
|
||||||
|
|
||||||
|
Translation layer of the BAL plugin (gettext domain ``bal``).
|
||||||
|
|
||||||
|
Why BAL has its own catalog
|
||||||
|
---------------------------
|
||||||
|
Electrum translates its interface with gettext (domain ``electrum``). Its
|
||||||
|
translators only see Electrum's own source code, so the texts of an external
|
||||||
|
(zip) plugin such as BAL are not in Electrum's catalog, and Electrum offers no
|
||||||
|
way to translate external plugins. BAL therefore ships its own compiled
|
||||||
|
catalogs inside the plugin: ``locale/<lang>/LC_MESSAGES/bal.mo``.
|
||||||
|
|
||||||
|
Lookup order
|
||||||
|
------------
|
||||||
|
:func:`_` returns the first translation found in:
|
||||||
|
|
||||||
|
1. **Electrum's catalog.** When Electrum already translates the exact same
|
||||||
|
English text, its translation wins. BAL then reads like Electrum and
|
||||||
|
follows Electrum's own translation fixes; if Electrum ever ships BAL as an
|
||||||
|
internal plugin, Electrum's translations take over with no change here
|
||||||
|
(owner's decision D7 in ``PLAN_I18N.md``).
|
||||||
|
2. **BAL's catalog**, for the texts that only BAL has.
|
||||||
|
3. **The English source text.**
|
||||||
|
|
||||||
|
Language
|
||||||
|
--------
|
||||||
|
Electrum picks the GUI language once, at start-up (``run_electrum``), and a
|
||||||
|
change needs a restart. :func:`init_from_config` repeats Electrum's choice
|
||||||
|
once, when the Qt plugin is created. BAL never detects the language by
|
||||||
|
itself: otherwise part of a window could be in one language and part in
|
||||||
|
another. The CLI never calls it, so the CLI stays in English, like Electrum's.
|
||||||
|
|
||||||
|
Rules for translatable texts
|
||||||
|
----------------------------
|
||||||
|
* Wrap literal English text only: ``_("Sign the will")``.
|
||||||
|
* Put the variable parts in ``{}`` and fill them *after* translating:
|
||||||
|
``_("{} heirs").format(count)``. Never build the text with an f-string or
|
||||||
|
``%`` inside ``_()``: it would change at run time and never match a catalog
|
||||||
|
entry (Ruff rules INT001-INT003 catch this).
|
||||||
|
* Texts evaluated at import time (class attributes, tables, constants) are
|
||||||
|
marked with :func:`N_` and passed to ``_()`` when they are displayed. Every
|
||||||
|
class body runs before :func:`init_from_config`, so a ``_()`` there would
|
||||||
|
always return English.
|
||||||
|
* Never translate text that is stored or compared (wallet labels, the saved
|
||||||
|
status history, config values used to recognise BAL transactions): stored
|
||||||
|
data must not depend on the UI language.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gettext
|
||||||
|
import io
|
||||||
|
import string
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from electrum.i18n import _ as _electrum_gettext
|
||||||
|
from electrum.logging import get_logger
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
#: gettext domain of the BAL catalogs.
|
||||||
|
DOMAIN = "bal"
|
||||||
|
|
||||||
|
# BAL catalog of the current language, or None (English, CLI, or no usable
|
||||||
|
# catalog). Set by set_language().
|
||||||
|
_catalog: Optional[gettext.GNUTranslations] = None
|
||||||
|
|
||||||
|
# Source texts whose BAL translation was rejected: the warning is logged once,
|
||||||
|
# not at every repaint of the widget that shows the text.
|
||||||
|
_rejected: set = set()
|
||||||
|
|
||||||
|
_formatter = string.Formatter()
|
||||||
|
|
||||||
|
|
||||||
|
def keeps_format_fields(msg: str, translation: str) -> bool:
|
||||||
|
"""Tell whether ``translation`` keeps the ``{}`` fields of ``msg``.
|
||||||
|
|
||||||
|
A translation that adds, drops or renames a replacement field would make
|
||||||
|
the ``.format()`` call that follows raise, or put a value in the wrong
|
||||||
|
place, so such a translation must not be used.
|
||||||
|
|
||||||
|
The rules are copied from Electrum's
|
||||||
|
``_ensure_translation_keeps_format_string_syntax_similar``
|
||||||
|
(``electrum/i18n.py``, MIT licence, Copyright (C) The Electrum developers)
|
||||||
|
rather than importing that private function, which may change without
|
||||||
|
notice. Keep them identical, so a BAL translation is accepted or rejected
|
||||||
|
exactly like an Electrum one. The only addition: a malformed source text
|
||||||
|
returns False here instead of raising.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Tuples of (literal_text, field_name, format_spec, conversion).
|
||||||
|
parsed1 = list(_formatter.parse(msg))
|
||||||
|
parsed2 = list(_formatter.parse(translation))
|
||||||
|
except ValueError: # malformed format string
|
||||||
|
return False
|
||||||
|
if len(parsed1) != len(parsed2):
|
||||||
|
return False
|
||||||
|
# The set of field names must not change (re-ordering them is allowed).
|
||||||
|
return {t[1] for t in parsed1} == {t[1] for t in parsed2}
|
||||||
|
|
||||||
|
|
||||||
|
def _(msg: str) -> str:
|
||||||
|
"""Return ``msg`` translated into the GUI language.
|
||||||
|
|
||||||
|
See the module docstring for the lookup order. ``_("")`` returns ``""``:
|
||||||
|
for gettext the empty text is the key of the catalog header.
|
||||||
|
"""
|
||||||
|
if msg == "":
|
||||||
|
return ""
|
||||||
|
translation = _electrum_gettext(msg)
|
||||||
|
if translation != msg or _catalog is None:
|
||||||
|
# Electrum's translation wins (Electrum has already checked its {}
|
||||||
|
# fields); without a BAL catalog there is nothing else to look up.
|
||||||
|
return translation
|
||||||
|
translation = _catalog.gettext(msg)
|
||||||
|
if translation == msg or keeps_format_fields(msg, translation):
|
||||||
|
return translation
|
||||||
|
if msg not in _rejected:
|
||||||
|
_rejected.add(msg)
|
||||||
|
_logger.warning(
|
||||||
|
f"rejected BAL translation, replacement fields differ: "
|
||||||
|
f"{msg!r} -> {translation!r}"
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def N_(msg: str) -> str: # noqa: N802 (the conventional gettext marker name)
|
||||||
|
"""Mark ``msg`` as translatable and return it unchanged.
|
||||||
|
|
||||||
|
Used where a text is defined at import time (class attributes, tables,
|
||||||
|
constants). Extraction tools (Babel, xgettext) collect ``N_("...")`` like
|
||||||
|
``_("...")``; the code passes the value to ``_()`` when it shows it.
|
||||||
|
"""
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def set_language(plugin, lang: Optional[str]) -> None:
|
||||||
|
"""Load the BAL catalog of ``lang`` (for example ``"it_IT"``), or none.
|
||||||
|
|
||||||
|
``None``, ``""`` and English (``"en_*"``) load no catalog, so the English
|
||||||
|
source texts are shown, as Electrum does for ``en_*``.
|
||||||
|
|
||||||
|
The catalog is read with ``plugin.read_file()``, the Electrum API BAL uses
|
||||||
|
for its icons: it works both from the installed zip and from a development
|
||||||
|
checkout. ``gettext.translation(localedir=...)`` is not used because,
|
||||||
|
pointed inside a zip, it silently finds nothing.
|
||||||
|
|
||||||
|
If ``locale/<lang>/`` has no catalog, the plain language code is tried
|
||||||
|
(``locale/it/`` for ``it_IT``). A missing or unreadable catalog is logged
|
||||||
|
and BAL stays in English: a translation problem must never stop the
|
||||||
|
plugin.
|
||||||
|
"""
|
||||||
|
global _catalog
|
||||||
|
_catalog = None
|
||||||
|
_rejected.clear()
|
||||||
|
if not isinstance(lang, str) or not lang or lang.startswith("en_"):
|
||||||
|
return
|
||||||
|
codes = [lang]
|
||||||
|
if "_" in lang:
|
||||||
|
codes.append(lang.split("_", 1)[0])
|
||||||
|
for code in codes:
|
||||||
|
path = "locale/{}/LC_MESSAGES/{}.mo".format(code, DOMAIN)
|
||||||
|
try:
|
||||||
|
data = plugin.read_file(path)
|
||||||
|
except Exception:
|
||||||
|
# Not shipped for this code (a zip raises KeyError, a development
|
||||||
|
# folder raises OSError): try the next code.
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_catalog = gettext.GNUTranslations(io.BytesIO(data))
|
||||||
|
except Exception as e:
|
||||||
|
_logger.info(f"unreadable BAL catalog {path}, using English: {e!r}")
|
||||||
|
return
|
||||||
|
_logger.info(f"BAL catalog loaded: {path}")
|
||||||
|
return
|
||||||
|
_logger.info(f"no BAL catalog for {lang!r}, using English")
|
||||||
|
|
||||||
|
|
||||||
|
def init_from_config(plugin, config) -> None:
|
||||||
|
"""Load the BAL catalog of the language Electrum's Qt GUI is using.
|
||||||
|
|
||||||
|
Repeats Electrum's own choice in ``run_electrum``: the ``language``
|
||||||
|
setting (Preferences > Language) or, when it is empty, the system language
|
||||||
|
if Electrum supports it, else English. Called once, from the Qt plugin's
|
||||||
|
``__init__``. Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lang = config.LOCALIZATION_LANGUAGE
|
||||||
|
if not lang:
|
||||||
|
from electrum.gui.default_lang import get_default_language
|
||||||
|
|
||||||
|
lang = get_default_language(gui_name="qt")
|
||||||
|
except Exception as e:
|
||||||
|
# Electrum also falls back to English when it cannot read the
|
||||||
|
# system language.
|
||||||
|
_logger.info(f"could not read the GUI language, using English: {e!r}")
|
||||||
|
lang = None
|
||||||
|
set_language(plugin, lang)
|
||||||
1820
bal/locale/bal.pot
Normal file
1820
bal/locale/bal.pot
Normal file
File diff suppressed because it is too large
Load Diff
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/es_ES/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
2033
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
2033
bal/locale/it_IT/LC_MESSAGES/bal.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "bal",
|
"name": "bal",
|
||||||
"fullname": "Bitcoin After Life",
|
"fullname": "Bitcoin After Life",
|
||||||
"version": "0.5.18",
|
"version": "0.7.0",
|
||||||
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
"description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.",
|
||||||
"author": "Svatantrya",
|
"author": "Svatantrya",
|
||||||
"licence": "MIT",
|
"licence": "MIT",
|
||||||
"available_for": ["qt"],
|
"available_for": [
|
||||||
|
"qt",
|
||||||
|
"cmdline"
|
||||||
|
],
|
||||||
"icon": "icons/bal32x32.png"
|
"icon": "icons/bal32x32.png"
|
||||||
}
|
}
|
||||||
55
build_zip.py
55
build_zip.py
@@ -9,7 +9,12 @@ Electrum loads external plugins from a ``.zip`` using Python's ``zipimport``.
|
|||||||
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
* uses standard DEFLATE compression (well supported by ``zipimport``);
|
||||||
* emits entries in a deterministic, sorted order so the archive is
|
* emits entries in a deterministic, sorted order so the archive is
|
||||||
reproducible (stable SHA-256);
|
reproducible (stable SHA-256);
|
||||||
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files.
|
* skips ``__pycache__`` directories and compiled ``*.pyc``/``*.pyo`` files;
|
||||||
|
* compiles each translation catalog ``bal/locale/<lang>/LC_MESSAGES/bal.po``
|
||||||
|
into ``bal.mo`` inside the archive (the ``.mo`` files are not kept in
|
||||||
|
git, and the ``.po``/``.pot`` sources are not shipped). This needs Babel
|
||||||
|
(``pip install babel``); without it the build stops, so a release can
|
||||||
|
never ship without its translations by mistake.
|
||||||
|
|
||||||
The archive keeps the top-level ``bal/`` directory so that the package is
|
The archive keeps the top-level ``bal/`` directory so that the package is
|
||||||
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
importable as ``bal`` (and Electrum derives ``dirname='bal'`` from the path of
|
||||||
@@ -23,14 +28,36 @@ Prints the resulting size and SHA-256 so the download can be integrity-checked.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
SRC_ROOT = "bal"
|
SRC_ROOT = "bal"
|
||||||
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
DEFAULT_OUT = "bal-electrum-plugin.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def compile_catalog(po_path: str) -> bytes:
|
||||||
|
"""Return the compiled ``.mo`` bytes of the gettext catalog ``po_path``.
|
||||||
|
|
||||||
|
Fuzzy (unreviewed) entries are left out, as ``msgfmt`` does.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from babel.messages.mofile import write_mo
|
||||||
|
from babel.messages.pofile import read_po
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit(
|
||||||
|
"ERROR: Babel is needed to compile the translations "
|
||||||
|
"(pip install babel)"
|
||||||
|
) from None
|
||||||
|
with open(po_path, "rb") as f:
|
||||||
|
catalog = read_po(f)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
write_mo(buf, catalog, use_fuzzy=False)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def build(out_path: str) -> None:
|
def build(out_path: str) -> None:
|
||||||
if os.path.exists(out_path):
|
if os.path.exists(out_path):
|
||||||
os.remove(out_path)
|
os.remove(out_path)
|
||||||
@@ -40,17 +67,34 @@ def build(out_path: str) -> None:
|
|||||||
# prune cache dirs in place so os.walk does not descend into them
|
# prune cache dirs in place so os.walk does not descend into them
|
||||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||||
for fn in filenames:
|
for fn in filenames:
|
||||||
if fn.endswith((".pyc", ".pyo")):
|
# .mo files are rebuilt from the .po sources below; the .po/.pot
|
||||||
|
# sources themselves are not shipped.
|
||||||
|
if fn.endswith((".pyc", ".pyo", ".po", ".pot", ".mo")):
|
||||||
continue
|
continue
|
||||||
files.append(os.path.join(dirpath, fn))
|
files.append(os.path.join(dirpath, fn))
|
||||||
|
catalogs = {} # archive path of the .mo -> source .po
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(os.path.join(SRC_ROOT, "locale")):
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.endswith(".po"):
|
||||||
|
po = os.path.join(dirpath, fn)
|
||||||
|
catalogs[po[: -len(".po")] + ".mo"] = po
|
||||||
files.sort()
|
files.sort()
|
||||||
|
|
||||||
with zipfile.ZipFile(
|
with zipfile.ZipFile(
|
||||||
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
out_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6
|
||||||
) as z:
|
) as z:
|
||||||
for f in files:
|
entries = [(f, None) for f in files] + [
|
||||||
|
(mo, po) for mo, po in catalogs.items()
|
||||||
|
]
|
||||||
|
for f, po in sorted(entries):
|
||||||
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
arc = f.replace(os.sep, "/") # forward slashes inside the archive
|
||||||
z.write(f, arc)
|
if po is None:
|
||||||
|
z.write(f, arc)
|
||||||
|
else:
|
||||||
|
# Same timestamp rule as z.write(): the source file's mtime.
|
||||||
|
info = zipfile.ZipInfo(arc, time.localtime(os.path.getmtime(po))[:6])
|
||||||
|
info.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
z.writestr(info, compile_catalog(po))
|
||||||
|
|
||||||
# Integrity + summary
|
# Integrity + summary
|
||||||
with zipfile.ZipFile(out_path) as z:
|
with zipfile.ZipFile(out_path) as z:
|
||||||
@@ -63,7 +107,8 @@ def build(out_path: str) -> None:
|
|||||||
|
|
||||||
data = open(out_path, "rb").read()
|
data = open(out_path, "rb").read()
|
||||||
print(f"built : {out_path}")
|
print(f"built : {out_path}")
|
||||||
print(f"files : {len(files)}")
|
print(f"files : {len(files) + len(catalogs)}")
|
||||||
|
print(f"langs : {', '.join(sorted(os.path.basename(os.path.dirname(os.path.dirname(m))) for m in catalogs)) or '-'}")
|
||||||
print(f"size : {len(data)} bytes")
|
print(f"size : {len(data)} bytes")
|
||||||
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
print(f"sha256: {hashlib.sha256(data).hexdigest()}")
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
Documentation for the **BAL** open‑source Electrum plugin for Bitcoin digital
|
||||||
inheritance. Everything here is plain Markdown + images (and optional styled
|
inheritance. Everything here is plain Markdown + images (and optional styled
|
||||||
HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed**.
|
HTML), so it renders directly on any forge (Gitea/GitHub) and in any browser —
|
||||||
|
**no PDF needed**.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
| Document | Markdown (GitHub) | Styled HTML |
|
| Document | Markdown | Styled HTML |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
|
| **User Manual (revB)** — full plugin manual with screenshots | [`manual/README.md`](./manual/README.md) | [`manual/manual.html`](./manual/manual.html) |
|
||||||
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
|
| **Inheritance Options Guide** — every change (date earlier/later, add/remove heir, change %, fees, executors) + decision flow chart + transaction states & server effects | [`inheritance-options.md`](./inheritance-options.md) | [`inheritance-options.html`](./inheritance-options.html) |
|
||||||
@@ -23,9 +24,8 @@ HTML), so it renders directly on GitHub and via GitHub Pages — **no PDF needed
|
|||||||
|
|
||||||
## Viewing the HTML versions
|
## Viewing the HTML versions
|
||||||
|
|
||||||
- On GitHub Pages: enable Pages for this repository (Settings → Pages → deploy
|
- Online: serve the `docs/` folder as static files (e.g. Pages on Gitea or
|
||||||
from branch, folder `/docs`), then open
|
GitHub) and open `manual/manual.html`.
|
||||||
`https://<owner>.github.io/<repo>/manual/manual.html`.
|
|
||||||
- Offline: download the `docs/` folder and open the `.html` files in any browser
|
- Offline: download the `docs/` folder and open the `.html` files in any browser
|
||||||
(the styled manual works fully offline; the inheritance‑options page loads
|
(the styled manual works fully offline; the inheritance‑options page loads
|
||||||
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).
|
Mermaid from a CDN for the live diagram, and also ships a static SVG fallback).
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ executor that <em>should</em> hold your tx did not return it — re‑Broadcast
|
|||||||
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
|
<li><strong>Mind the dust limit.</strong> A share below Bitcoin's dust limit is skipped; if <strong>every</strong> heir is dust the build is blocked with a clear message (§4.8) — raise the amounts or use fewer heirs.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<footer>This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
<footer>This document reflects BAL plugin v0.7.0. Behaviour is derived directly from
|
||||||
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
<code>core/will.py</code>, <code>core/heirs.py</code> and <code>gui/qt/window.py</code>.</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ you change anything in your will, BAL must decide between three outcomes:
|
|||||||
The whole point of rule 3 is safety: **a will‑executor must never be able to
|
The whole point of rule 3 is safety: **a will‑executor must never be able to
|
||||||
broadcast an old transaction that would execute your inheritance too early.**
|
broadcast an old transaction that would execute your inheritance too early.**
|
||||||
|
|
||||||
|
**Check‑Alive relative values are signed.** A RELATIVE Check‑Alive
|
||||||
|
(``-30d`` / ``30d``) is anchored according to its sign:
|
||||||
|
|
||||||
|
- **NEGATIVE** (``-30d``) counts **backwards from the delivery date** — the
|
||||||
|
classic "check alive 30 days before the delivery".
|
||||||
|
- **POSITIVE** (``30d``) counts **forward from the inheritance creation date**
|
||||||
|
(the date the will was built), so it stays fixed while the will is unchanged.
|
||||||
|
|
||||||
|
The Check‑Alive date must always be **after today** and **before the delivery**;
|
||||||
|
when it is not, BAL raises `CheckAliveAfterDeliveryError` (a configuration error
|
||||||
|
that aborts the action and never invalidates the will).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Transaction states (status flags)
|
## 2. Transaction states (status flags)
|
||||||
@@ -43,6 +55,7 @@ important ones:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
|
| `VALID` | The item is the current, usable plan | default `True`; cleared by INVALIDATED/REPLACED/CONFIRMED/MEMPOOL |
|
||||||
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
|
| `COMPLETE` (*Signed*) | The transaction has been **signed** | after you press **Sign** |
|
||||||
|
| `PARTIALLY_SIGNED` | Only **some** of the required signatures are present | a multisig will after a partial sign (cleared by `COMPLETE`) |
|
||||||
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
| `PUSHED` | The signed tx was **sent to the will‑executor(s)** | after **Broadcast** to executors |
|
||||||
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
| `CHECKED` | The will‑executor **confirmed** it holds the tx | after a successful server **Check** (implies `PUSHED`) |
|
||||||
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
|
| `CHECK_FAIL` | The server **check failed** | a queried executor did not return the tx |
|
||||||
@@ -65,6 +78,7 @@ Flag transitions enforced by `set_status` (the safety rules baked in the code):
|
|||||||
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
|
- Setting `CONFIRMED` / `MEMPOOL` → clears `INVALIDATED`.
|
||||||
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
|
- Setting `PUSHED` → clears `PUSH_FAIL` **and** `CHECK_FAIL`.
|
||||||
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
|
- Setting `CHECKED` → implies `PUSHED` (and clears `PUSH_FAIL`).
|
||||||
|
- Setting `COMPLETE` → clears `PARTIALLY_SIGNED`.
|
||||||
|
|
||||||
### How states map to row colour in the list
|
### How states map to row colour in the list
|
||||||
|
|
||||||
@@ -83,7 +97,8 @@ wins:
|
|||||||
| 7 | `CHECKED` | green | `#8afa6c` |
|
| 7 | `CHECKED` | green | `#8afa6c` |
|
||||||
| 8 | `PUSH_FAIL` | red | `#e83845` |
|
| 8 | `PUSH_FAIL` | red | `#e83845` |
|
||||||
| 9 | `PUSHED` | teal | `#73f3c8` |
|
| 9 | `PUSHED` | teal | `#73f3c8` |
|
||||||
| 10 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
| 10 | `PARTIALLY_SIGNED` | amber | `#ffb347` |
|
||||||
|
| 11 | `COMPLETE` (signed, **not** yet pushed) | blue | `#2bc8ed` |
|
||||||
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
|
| — | none of the above (e.g. plain `VALID`, prepared) | default white | `#ffffff` |
|
||||||
|
|
||||||
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
|
> **Note (v0.3.3 fix):** a will that is *signed but not yet broadcast*
|
||||||
@@ -161,6 +176,7 @@ exactly what the will‑executors hold — not the in‑memory copy.
|
|||||||
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
|
| **Move date EARLIER, still in the future** (anticipate) | **any** (signed or not) | Plain **rebuild** with the new earlier locktime (`HeirNotFound` fall‑through) — **never** an on‑chain invalidation | **No** |
|
||||||
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
|
| **Move date EARLIER into the past** (new date already passed) | — | Will is genuinely **expired** → **invalidate** (`WillExpired`) | **Yes** |
|
||||||
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
| Check‑Alive threshold already passed | — | **Invalidate** (`CheckAliveError`) | **Yes** |
|
||||||
|
| Check‑Alive date not before the delivery (config error) | — | **Abort**, no rebuild/invalidate (`CheckAliveAfterDeliveryError`) | **No** |
|
||||||
|
|
||||||
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
> **Why postpone needs an on‑chain invalidation:** the will‑executor still holds
|
||||||
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a
|
> the *old* transaction with the *earlier* locktime. If you simply re‑signed a
|
||||||
@@ -354,5 +370,5 @@ that limit.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This document reflects BAL plugin v0.4.7. Behaviour is derived directly from
|
*This document reflects the current BAL plugin (v0.7.0). Behaviour is derived
|
||||||
`core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
directly from `core/will.py`, `core/heirs.py` and `gui/qt/window.py`.*
|
||||||
|
|||||||
@@ -76,6 +76,22 @@ inheritance cases.
|
|||||||
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
|
*Figure 2 — the parameters on the HEIRS tab: (1) Delivery Time, (2) Check Alive,
|
||||||
(3) Fees.*
|
(3) Fees.*
|
||||||
|
|
||||||
|
### User type: BASIC / ADVANCED
|
||||||
|
|
||||||
|
The plugin has two usage modes, chosen from the plugin settings
|
||||||
|
(**Tools → Plugins → BAL**, *User Type* selector):
|
||||||
|
|
||||||
|
- **BASIC** (default) — hides the advanced controls: the **Delivery Time** is
|
||||||
|
entered only as a precise **Date** (the relative **RAW** durations and the
|
||||||
|
Raw/Date selector are hidden), the **Check Alive** field is hidden, and the
|
||||||
|
postpone-on-open behaviour is disabled.
|
||||||
|
- **ADVANCED** — reveals the **Raw/Date selector** (relative durations such as
|
||||||
|
`1y` or `30d`) and the **Check Alive** field, and enables the postpone
|
||||||
|
behaviour described below. Switching to ADVANCED requires typing the
|
||||||
|
confirmation phrase **"at My Risk"**.
|
||||||
|
|
||||||
|
The rest of this section describes the full (ADVANCED) parameter set.
|
||||||
|
|
||||||
### 1 — Delivery Time (Locktime)
|
### 1 — Delivery Time (Locktime)
|
||||||
|
|
||||||
Indicates the date on which the inheritance of your wallet on the blockchain
|
Indicates the date on which the inheritance of your wallet on the blockchain
|
||||||
@@ -94,6 +110,11 @@ If you choose **Raw**, you can insert various options based on a suffix:
|
|||||||
|
|
||||||
*(i.e. check whether you are still alive, and then postpone the inheritance.)*
|
*(i.e. check whether you are still alive, and then postpone the inheritance.)*
|
||||||
|
|
||||||
|
> **NB:** the **Check Alive** parameter is available only in **ADVANCED** mode.
|
||||||
|
> In **BASIC** (default) it is hidden and the plugin re-evaluates the will
|
||||||
|
> against "now" every time you open Electrum, so the postpone behaviour
|
||||||
|
> described here does not apply.
|
||||||
|
|
||||||
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
|
This parameter — settable as relative (`RAW`) or absolute (`DATE`) — indicates
|
||||||
the time by which the inheritance will **not** be changed by postponing it.
|
the time by which the inheritance will **not** be changed by postponing it.
|
||||||
|
|
||||||
@@ -223,6 +244,9 @@ plugin will notify you that you need to update the inheritance.
|
|||||||
|
|
||||||
## RAW settings
|
## RAW settings
|
||||||
|
|
||||||
|
> **NB:** relative (**RAW**) durations are available only in **ADVANCED** mode;
|
||||||
|
> in **BASIC** the Delivery Time is entered only as a precise date.
|
||||||
|
|
||||||
If you set, for example, `RAW‑1d` and it is, say, 5 p.m., the plugin will not
|
If you set, for example, `RAW‑1d` and it is, say, 5 p.m., the plugin will not
|
||||||
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
|
execute the inheritance precisely 24 hours later (5 p.m. the next day) but will
|
||||||
roughly estimate the blockchain block number corresponding to that time — so
|
roughly estimate the blockchain block number corresponding to that time — so
|
||||||
@@ -239,7 +263,8 @@ with a tolerance of a few hours.
|
|||||||
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
|
If you want a quick test run, enter an upcoming legacy date/time (e.g. 18 hours
|
||||||
later). For such short intervals the **Check Alive** could create problems, so
|
later). For such short intervals the **Check Alive** could create problems, so
|
||||||
set the Check Alive parameter **in the past** (a date before today) — e.g. a
|
set the Check Alive parameter **in the past** (a date before today) — e.g. a
|
||||||
previous month.
|
previous month. *(The Check Alive only exists in **ADVANCED** mode; in
|
||||||
|
**BASIC** this is not needed.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -335,7 +360,7 @@ inheritance:
|
|||||||
|
|
||||||
## Will‑Executor service list
|
## Will‑Executor service list
|
||||||
|
|
||||||
This window opens from the Electrum menu, **Tools → Will‑executor**, and shows
|
This window opens from the Electrum menu, **Tools → Will‑Executors**, and shows
|
||||||
the official list of will‑executor servers.
|
the official list of will‑executor servers.
|
||||||
|
|
||||||
If you want to make changes — such as adding an additional will‑executor server —
|
If you want to make changes — such as adding an additional will‑executor server —
|
||||||
@@ -409,14 +434,16 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
|||||||
| # | Status | Meaning | Colour | HEX |
|
| # | Status | Meaning | Colour | HEX |
|
||||||
|---|--------|---------|--------|-----|
|
|---|--------|---------|--------|-----|
|
||||||
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
|
| 1 | **New** | TX new inheritance | White (transparent) | `#FFFFFF` |
|
||||||
| 2 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
| 2 | **Partially signed** | TX has some, but not all, of the required signatures | Amber | `#FFB347` |
|
||||||
| 3 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
| 3 | **Signed** | TX inheritance signed into the wallet | Azure | `#2BC8ED` |
|
||||||
| 4 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
| 4 | **Pushed** | TX sent to will‑executor | Azure‑green | `#73F3C8` |
|
||||||
| 5 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
| 5 | **Checked** | TX actually present in the will‑executor | Bright green | `#8AFA6C` |
|
||||||
| 6 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
| 6 | **Confirmed** | TX confirmed in the blockchain | Gray | `#BFBFBF` |
|
||||||
| 7 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
| 7 | **Pending** | TX awaiting confirmation on blockchain | Yellow | `#FFCE30` |
|
||||||
| 8 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
| 8 | **Failed** | Communication failure with will‑executor | Red | `#E83845` |
|
||||||
| 9 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
| 9 | **Invalidated** | UTXO input is no longer available | Orange | `#F87838` |
|
||||||
|
| 10 | **Replaced** | A backdated‑locktime transaction spends the same input | Violet | `#FF97E9` |
|
||||||
|
| 11 | **Updated** | TX re‑issued keeping the same locktime and heirs | Light violet | `#B266B2` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -448,6 +475,27 @@ transactions can have in the WILL tab, on each will‑executor that is online.
|
|||||||
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
> **NB:** When you close Electrum, the plugin automatically proceeds to execute
|
||||||
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
> **Prepare → Sign → Broadcast** (if they have not already been completed) to
|
||||||
> ensure the inheritance is correctly executed.
|
> ensure the inheritance is correctly executed.
|
||||||
|
>
|
||||||
|
> Optionally, the **Rebuild on close** setting (available in **Tools → Plugins →
|
||||||
|
> BAL**, default OFF) skips the full wizard and runs a one-shot rebuild/sign/push
|
||||||
|
> flow when Electrum closes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-rebuild on new transactions
|
||||||
|
|
||||||
|
> **NB:** this feature requires the **Auto-rebuild** setting to be enabled
|
||||||
|
> (available in **Tools → Plugins → BAL**, default OFF).
|
||||||
|
|
||||||
|
When the **Auto-rebuild** setting is enabled, the plugin automatically rebuilds
|
||||||
|
the will when new transactions are detected in the wallet (e.g. incoming
|
||||||
|
payments). The delivery date is anticipated by one day so the new will orphans
|
||||||
|
the old one on-chain without requiring a manual invalidation. An on-chain
|
||||||
|
invalidation is only needed when the anticipated locktime crosses the **Check
|
||||||
|
Alive** threshold (ADVANCED mode only).
|
||||||
|
|
||||||
|
This is useful for wallets that receive funds regularly: the inheritance stays
|
||||||
|
up-to-date without manual intervention.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -500,6 +548,32 @@ value.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Command-line / headless usage
|
||||||
|
|
||||||
|
BAL can also be used without the Qt GUI, via Electrum's daemon mode. This is
|
||||||
|
useful for scripting, automation, or running on a headless server.
|
||||||
|
|
||||||
|
**Prerequisites:** an Electrum daemon (`electrum daemon -d`) and a loaded wallet
|
||||||
|
(`electrum load_wallet`).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
electrum daemon -d
|
||||||
|
electrum load_wallet
|
||||||
|
electrum bal_heirs_list
|
||||||
|
electrum bal_will_prepare
|
||||||
|
electrum bal_will_sign --password '...'
|
||||||
|
electrum bal_will_broadcast
|
||||||
|
electrum stop
|
||||||
|
```
|
||||||
|
|
||||||
|
All GUI operations (prepare, sign, broadcast, check, rebuild) are available as
|
||||||
|
`bal_*` commands. See the full command table in the
|
||||||
|
[README](../../README.md#command-line--headless-usage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
About installing a will‑executor server or collaboration, send your request to:
|
About installing a will‑executor server or collaboration, send your request to:
|
||||||
**info@bitcoin-after.life**
|
**info@bitcoin-after.life**
|
||||||
|
|
||||||
|
|||||||
285
make-release.sh
Executable file
285
make-release.sh
Executable file
@@ -0,0 +1,285 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# make-release.sh — Create a Gitea release for bal-electrum-plugin
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./make-release.sh # read version from bal/manifest.json
|
||||||
|
# ./make-release.sh v0.6.2 # bump manifest to 0.6.2, then release
|
||||||
|
#
|
||||||
|
# Requires: git, gpg, curl, python3, sha256sum
|
||||||
|
# Optional: ruff (lint skipped if not installed)
|
||||||
|
# Credentials: ~/.git-credentials or GITEA_USER / GITEA_TOKEN env vars
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
die() { echo "Error: $*" >&2; exit 1; }
|
||||||
|
info() { echo ""; echo "── $* ──"; }
|
||||||
|
|
||||||
|
# ── 0. Resolve version ──────────────────────────────────────────────
|
||||||
|
MANIFEST="bal/manifest.json"
|
||||||
|
[ -f "$MANIFEST" ] || die "manifest not found: $MANIFEST"
|
||||||
|
|
||||||
|
# read current version from manifest
|
||||||
|
CURRENT_VER=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])")
|
||||||
|
[ -n "$CURRENT_VER" ] || die "cannot read version from $MANIFEST"
|
||||||
|
|
||||||
|
ARG="${1:-}"
|
||||||
|
if [ -n "$ARG" ]; then
|
||||||
|
# normalise: accept "v0.6.2" or "0.6.2"
|
||||||
|
NEW_VER="${ARG#v}"
|
||||||
|
TAG="v${NEW_VER}"
|
||||||
|
else
|
||||||
|
TAG="v${CURRENT_VER}"
|
||||||
|
NEW_VER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Release ${TAG} ==="
|
||||||
|
echo "Current manifest version: ${CURRENT_VER}"
|
||||||
|
[ -n "$NEW_VER" ] && echo "New version (will bump): ${NEW_VER}"
|
||||||
|
|
||||||
|
# ── 1. Bump version in manifest (if arg provided) ──────────────────
|
||||||
|
if [ -n "$NEW_VER" ] && [ "$NEW_VER" != "$CURRENT_VER" ]; then
|
||||||
|
info "[1/10] Bumping version to ${NEW_VER} in ${MANIFEST}"
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
f = open('${MANIFEST}')
|
||||||
|
d = json.load(f); f.close()
|
||||||
|
d['version'] = '${NEW_VER}'
|
||||||
|
json.dump(d, open('${MANIFEST}', 'w'), indent=4, ensure_ascii=False)
|
||||||
|
print(json.dumps(d, indent=4, ensure_ascii=False))
|
||||||
|
"
|
||||||
|
git add "$MANIFEST"
|
||||||
|
else
|
||||||
|
info "[1/10] Version already ${CURRENT_VER}, no bump needed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Clean caches ─────────────────────────────────────────────────
|
||||||
|
info "[2/10] Cleaning __pycache__ and .pyc"
|
||||||
|
find bal -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find bal -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
find bal -name "*.pyo" -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 3. Run tests ────────────────────────────────────────────────────
|
||||||
|
info "[3/10] Running test suite"
|
||||||
|
if QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 -m pytest \
|
||||||
|
tests/test_core_*.py \
|
||||||
|
tests/test_anticipate_past_locktime.py tests/test_anticipate_manual_locktime.py \
|
||||||
|
tests/test_group_b_auto_sign.py tests/test_group_c_settings.py \
|
||||||
|
tests/test_group_d_alarms.py tests/test_group_e_mock_karen7.py \
|
||||||
|
tests/test_group_f_heir_change_rebuild.py tests/test_group_g_basic_calendar.py \
|
||||||
|
tests/test_group_h_v048.py \
|
||||||
|
-q 2>&1; then
|
||||||
|
echo "All tests passed."
|
||||||
|
else
|
||||||
|
die "Tests failed — aborting release."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Lint (optional) ─────────────────────────────────────────────
|
||||||
|
info "[4/10] Lint with ruff"
|
||||||
|
if command -v ruff &>/dev/null; then
|
||||||
|
RUFF_ERRORS=$(ruff check bal/ 2>&1 \
|
||||||
|
| grep -oE "^[^ ]+\.py:[0-9]+:[0-9]+: [A-Z][0-9]+" \
|
||||||
|
| grep -vE "F401|F403|F405|F841" || true)
|
||||||
|
if [ -n "$RUFF_ERRORS" ]; then
|
||||||
|
echo "New ruff errors:"
|
||||||
|
echo "$RUFF_ERRORS"
|
||||||
|
die "Lint errors found — fix before releasing."
|
||||||
|
else
|
||||||
|
echo "Lint clean (ignoring known pre-existing warnings)."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ruff not installed — skipping lint."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Build ZIP via build_zip.py ───────────────────────────────────
|
||||||
|
info "[5/10] Building ZIP"
|
||||||
|
ZIP_NAME="bal_${TAG}.zip"
|
||||||
|
python3 build_zip.py "$ZIP_NAME"
|
||||||
|
|
||||||
|
# ── 6. GPG sign (armor + binary) + export public key ───────────────
|
||||||
|
info "[6/10] Signing with GPG"
|
||||||
|
GPG_KEY="A847D004DB91610711CA6A0DFE756706E833E0D1"
|
||||||
|
gpg --default-key "$GPG_KEY" --batch --yes --armor --detach-sign "$ZIP_NAME"
|
||||||
|
gpg --default-key "$GPG_KEY" --batch --yes --detach-sign "$ZIP_NAME"
|
||||||
|
ASC_FILE="${ZIP_NAME}.asc"
|
||||||
|
SIG_FILE="${ZIP_NAME}.sig"
|
||||||
|
PGP_FILE="svatantrya.asc"
|
||||||
|
gpg --armor --export "$GPG_KEY" > "$PGP_FILE"
|
||||||
|
echo " Signed: $ASC_FILE"
|
||||||
|
echo " Signed: $SIG_FILE"
|
||||||
|
echo " Public key: $PGP_FILE"
|
||||||
|
|
||||||
|
# ── 7. SHA-256 checksum ────────────────────────────────────────────
|
||||||
|
info "[7/10] Computing SHA-256"
|
||||||
|
SHA256_HASH=$(sha256sum "$ZIP_NAME" | cut -d' ' -f1)
|
||||||
|
echo "${SHA256_HASH} ${ZIP_NAME}" > "${ZIP_NAME}.sha256"
|
||||||
|
echo " SHA-256: ${SHA256_HASH}"
|
||||||
|
|
||||||
|
# ── 8. Pause for Electrum test ─────────────────────────────────────
|
||||||
|
info "[8/10] Test in Electrum (ZIP-FIRST policy)"
|
||||||
|
echo ""
|
||||||
|
echo " ZIP ready: $(pwd)/${ZIP_NAME}"
|
||||||
|
echo ""
|
||||||
|
echo " Install it in Electrum (Tools -> Plugins -> Install from file)."
|
||||||
|
echo " IMPORTANT: fully restart Electrum (not just reload the plugin)."
|
||||||
|
echo ""
|
||||||
|
read -r -p " Does the plugin work correctly in Electrum? [y/N] " CONFIRM
|
||||||
|
case "$CONFIRM" in
|
||||||
|
[yY][eE][sS]|[yY]) echo " Confirmed." ;;
|
||||||
|
*) die "Aborted by user." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── 9. Git tag + push ──────────────────────────────────────────────
|
||||||
|
info "[9/10] Creating and pushing tag ${TAG}"
|
||||||
|
ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)"
|
||||||
|
[ -n "$ORIGIN_URL" ] || die "no git remote 'origin' found"
|
||||||
|
|
||||||
|
GITEA_HOST="$(echo "$ORIGIN_URL" | sed -n 's|https://\([^/]*\)/.*|\1|p')"
|
||||||
|
[ -n "$GITEA_HOST" ] || die "cannot parse Gitea host from origin URL"
|
||||||
|
|
||||||
|
TARGET_REPO="bitcoinafterlife/bal-electrum-plugin"
|
||||||
|
|
||||||
|
# credentials
|
||||||
|
GITEA_USER="${GITEA_USER:-}"
|
||||||
|
GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||||
|
if [ -z "$GITEA_USER" ] && [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
if [ -f ~/.git-credentials ]; then
|
||||||
|
CREDS_LINE="$(grep "${GITEA_HOST}" ~/.git-credentials | head -n1)"
|
||||||
|
if [ -n "$CREDS_LINE" ]; then
|
||||||
|
CREDS="$(echo "$CREDS_LINE" | sed -n 's|https://\([^@]*\)@.*|\1|p')"
|
||||||
|
GITEA_USER="$(echo "$CREDS" | cut -d: -f1)"
|
||||||
|
GITEA_PASS="$(echo "$CREDS" | cut -d: -f2-)"
|
||||||
|
GITEA_TOKEN="$GITEA_PASS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -n "$GITEA_TOKEN" ] || die "GITEA_TOKEN not set and no credentials in ~/.git-credentials"
|
||||||
|
|
||||||
|
API="https://$GITEA_HOST/gitea/api/v1"
|
||||||
|
|
||||||
|
# create annotated tag
|
||||||
|
git tag -d "$TAG" 2>/dev/null || true
|
||||||
|
git tag -a "$TAG" -m "$TAG" HEAD
|
||||||
|
|
||||||
|
# push tag
|
||||||
|
REMOTE_NAME="gitea-target"
|
||||||
|
REMOTE_URL="https://$GITEA_USER:$GITEA_TOKEN@$GITEA_HOST/gitea/$TARGET_REPO.git"
|
||||||
|
git remote rm "$REMOTE_NAME" 2>/dev/null || true
|
||||||
|
git remote add "$REMOTE_NAME" "$REMOTE_URL"
|
||||||
|
echo " Pushing tag ${TAG} to ${TARGET_REPO}..."
|
||||||
|
git push "$REMOTE_NAME" "$TAG" --force
|
||||||
|
|
||||||
|
# ── 10. Create release + upload assets ──────────────────────────────
|
||||||
|
info "[10/10] Creating Gitea release"
|
||||||
|
|
||||||
|
RELEASE_BODY=$(python3 -c "
|
||||||
|
import json
|
||||||
|
|
||||||
|
sha256 = '${SHA256_HASH}'
|
||||||
|
zip_name = '${ZIP_NAME}'
|
||||||
|
asc_name = '${ASC_FILE}'
|
||||||
|
sig_name = '${SIG_FILE}'
|
||||||
|
pgp_file = '${PGP_FILE}'
|
||||||
|
tag = '${TAG}'
|
||||||
|
|
||||||
|
body = f'''Release {tag}
|
||||||
|
|
||||||
|
## SHA-256 Checksum
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
{sha256} {zip_name}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Verify SHA-256
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
sha256sum -c {zip_name}.sha256
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
- \`{zip_name}\` - Plugin BAL {tag}
|
||||||
|
- \`{asc_name}\` - GPG signature (armor)
|
||||||
|
- \`{sig_name}\` - GPG signature (binary)
|
||||||
|
- \`{pgp_file}\` - Signing public key ([also available online](https://bitcoin-after.life/svatantrya.asc))
|
||||||
|
|
||||||
|
## GPG Verification
|
||||||
|
|
||||||
|
### Import the signing key
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
gpg --fetch-key https://bitcoin-after.life/svatantrya.asc
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Or download \`{pgp_file}\` from the assets above:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
gpg --import {pgp_file}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Verify the signature (armor)
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
gpg --verify {asc_name} {zip_name}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Verify the signature (binary)
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
gpg --verify {sig_name} {zip_name}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
\`\`\`
|
||||||
|
gpg: Good signature from "Svātantrya <svatantrya@bitcoin-after.life>"
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Fingerprint: \`A847D004DB91610711CA6A0DFE756706E833E0D1\`
|
||||||
|
Public key: https://bitcoin-after.life/svatantrya.asc'''
|
||||||
|
|
||||||
|
print(json.dumps({'body': body}, ensure_ascii=False))
|
||||||
|
")
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-X POST "${API}/repos/${TARGET_REPO}/releases" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${RELEASE_BODY},\"draft\":false,\"prerelease\":false}")
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Error creating release: HTTP $HTTP_CODE"
|
||||||
|
echo "$BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
||||||
|
HTML_URL=$(echo "$BODY" | python3 -c "import json,sys; print(json.load(sys.stdin)['html_url'])")
|
||||||
|
echo " Release created: $HTML_URL (ID: $RELEASE_ID)"
|
||||||
|
|
||||||
|
# upload assets
|
||||||
|
for FILE in "$ZIP_NAME" "$ASC_FILE" "$SIG_FILE" "${ZIP_NAME}.sha256" "$PGP_FILE"; do
|
||||||
|
BASENAME=$(basename "$FILE")
|
||||||
|
echo " Uploading $BASENAME ..."
|
||||||
|
UPLOAD=$(curl -s -w "\n%{http_code}" -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-X POST "${API}/repos/${TARGET_REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
-F "attachment=@${FILE}" -F "name=${BASENAME}")
|
||||||
|
UPLOAD_CODE=$(echo "$UPLOAD" | tail -1)
|
||||||
|
if [ "$UPLOAD_CODE" == "201" ]; then
|
||||||
|
echo " OK ($BASENAME)"
|
||||||
|
else
|
||||||
|
echo " FAILED ($BASENAME) - HTTP $UPLOAD_CODE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Done ==="
|
||||||
|
echo "Release: $HTML_URL"
|
||||||
|
echo "Assets:"
|
||||||
|
echo " ${ZIP_NAME}"
|
||||||
|
echo " ${ASC_FILE}"
|
||||||
|
echo " ${SIG_FILE}"
|
||||||
|
echo " ${ZIP_NAME}.sha256"
|
||||||
|
echo " ${PGP_FILE}"
|
||||||
|
echo "SHA-256: ${SHA256_HASH}"
|
||||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select =["E", "W", "F", "I", "N", "B", "INT"] # INT: gettext rules (see bal/i18n.py)
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pep8-naming]
|
||||||
|
classmethod-decorators = ["classmethod", "classproperty"] # electrum.util.classproperty uses cls
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"bal/gui/qt/common.py" = ["F401"] # re-exports consumed via explicit imports
|
||||||
|
"bal/gui/qt/dialogs.py" = ["N802"] # Qt overrides: closeEvent/hideEvent/getText
|
||||||
|
"bal/gui/qt/lists.py" = ["N802"] # Qt overrides: createEditor/setEditorData/setModelData
|
||||||
|
"bal/gui/qt/widgets.py" = ["N802", "N815"] # Qt overrides + Qt signal attrs (valueChanged, ...)
|
||||||
|
"bal/gui/qt/window.py" = ["N802"] # getMsg
|
||||||
|
"bal/core/heirs.py" = ["N818", "N802"] # public exception names + buildTransactions API
|
||||||
|
"bal/core/will.py" = ["N818"] # public exception names
|
||||||
|
"bal/core/willexecutors.py" = ["N818"] # public exception names
|
||||||
|
"tests/*.py" = ["N802", "E402"] # deliberate UPPER_CASE helpers + sys.path-before-import
|
||||||
30
svatantrya.asc
Normal file
30
svatantrya.asc
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQENBGfgPmMBCAC4VXQn/ofBGPn/Wr9dF4tM/4uYNcWLvvz+/+TQsCi/bv4GG6jf
|
||||||
|
6Ttlg4TDwqF3JlZ1YfPImcdWKxr9is4fyq12OEZvz12LoFEJG8+0NdJrCoT2sm2f
|
||||||
|
yGmWKgZqRzH9LVBtIOOQIrXF3PdE0X77trWnSFrK/qAv9dszYiVOk9IBwUVI/3Wp
|
||||||
|
PN5EV7zqbCjYvzD0Hxl2sFzZKqsZCsiy70PJtaJKvKISd8RVTNuIiwZj0gu6hCSa
|
||||||
|
ZnBr5SLLr56YO4xaTzYNYh7XIEaQXZTHugEJbwygfZajnJ8gC91wWB3BsxVeHDdm
|
||||||
|
uDy1VGkAs65qvRn9ml5udmnEIPoEsS95HblpABEBAAG0K1N2xIF0YW50cnlhIDxz
|
||||||
|
dmF0YW50cnlhQGJpdGNvaW4tYWZ0ZXIubGlmZT6JAU4EEwEKADgWIQSoR9AE25Fh
|
||||||
|
BxHKag3+dWcG6DPg0QUCZ+A+YwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK
|
||||||
|
CRD+dWcG6DPg0coSCACbu3/tqMTwWTqRzXedl6VTGng+qeYfA5NYUaRgZeQYcVWM
|
||||||
|
sUi4dTAthBUxU3axfcu3V/Vkonn/Hrghdjh94lfpNsgdBNi3c2elI1rHT3Yobkj+
|
||||||
|
ZsMEj91VlqV81uPFzfq8a/Pp7RIDhy1FJbIunmjnpD3GeJ7vVt76OOcyjV5hkGR0
|
||||||
|
YJ4JX9O1OOC6wqgR2HVCvXTw/3JhNbj4TS8wr7GGsVWwiotAwZw506vspQRBqeYB
|
||||||
|
T5Wo2lpEQtagWzIHtgy4A2iAoLQ45E0T1lkr+mZa3V7sucS6W/UXI7HTvqC7wbku
|
||||||
|
jef6Hxwzzw83TWqPkd4wywuHsDZ3+DTcIDaqP/ROuQENBGfgPmMBCAC69Y2n2Ogi
|
||||||
|
T7i4Pm4J0cQxLaqwvox3GWSRuBG0QlhsBr0ER5j5fRRDH85P/WyTcvs4/9mIZsSl
|
||||||
|
JyQH/Lfetr/76pFCyc2zhKxxS1miG3RWOuM7BOKbRjjiieBa6XAiyWStKp2ij8a/
|
||||||
|
kpqqgulLe1Tiq2SRPA8etqHGd7oR02fbEvzmsgiVqFOz3/tozp2jdC7zCKnp+XFZ
|
||||||
|
xMKqhIMgfZAxRmVl/qImH944ffcJU6M+qjEL3ENXpuDXpMSWI/indlbK06+R/UPA
|
||||||
|
hOxCOUSRPTeHzhQrJYUgH6Q6Q/cijpTQHVQFFqLXRKGgK7oE1QhmiNGNBeCVF7DP
|
||||||
|
hpcWrnUkY/xFABEBAAGJATYEGAEKACAWIQSoR9AE25FhBxHKag3+dWcG6DPg0QUC
|
||||||
|
Z+A+YwIbDAAKCRD+dWcG6DPg0ToJB/4t2V4FMqd2q00Sd+HmttZoAWNuklui8wO4
|
||||||
|
nrjfh3Rt0ZBYYk+egZXzPx8lr42Ec8T4h24oJPovMlDu1xN9seQDbVaYC1ICVsnp
|
||||||
|
6/yfh+elYT5egaAxm9oP9+lQHBB/qZNKrfAssMuVQOrVh5E+XxSz+KG28dQnCYUT
|
||||||
|
L0k5PCO1f4Jz4XZd5AunVbMQ4J1JawUDoEb/w3Mn9ALDMsdAcOYC6pGhFtV88cqu
|
||||||
|
IO/ekQV+M8LpRwyh+CiPzgqtN3Z09wHLXFUJYBixXrYbXxAbSqe0PhqAhEKApk2c
|
||||||
|
4vVkSTAi+bNpkt0QgJ194iTyK20jVw3/roq7sUtDD4FrUoQb7llP
|
||||||
|
=6EE4
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
18
tests/conftest.py
Normal file
18
tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Shared pytest fixtures.
|
||||||
|
|
||||||
|
Guards every test against cross-file network pollution: several karen7
|
||||||
|
regtest modules historically flipped ``electrum.constants.net`` to regtest at
|
||||||
|
import time, which broke unrelated offline tests (e.g. the CLI controller
|
||||||
|
suite) run in the same pytest process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from electrum import constants
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_network():
|
||||||
|
"""Snapshot ``constants.net`` before each test and restore it after."""
|
||||||
|
prev = constants.net
|
||||||
|
yield
|
||||||
|
constants.net = prev
|
||||||
@@ -33,7 +33,8 @@ def _active_source_without_strings(module) -> str:
|
|||||||
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
|
||||||
self.spans.append((node.lineno, node.end_lineno))
|
self.spans.append((node.lineno, node.end_lineno))
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
s = _S(); s.visit(tree)
|
s = _S()
|
||||||
|
s.visit(tree)
|
||||||
drop = set()
|
drop = set()
|
||||||
for a, b in s.spans:
|
for a, b in s.spans:
|
||||||
drop.update(range(a, b + 1))
|
drop.update(range(a, b + 1))
|
||||||
@@ -45,12 +46,13 @@ def _active_source_without_strings(module) -> str:
|
|||||||
|
|
||||||
def main(pkg: str) -> int:
|
def main(pkg: str) -> int:
|
||||||
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
||||||
app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
|
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
|
||||||
|
|
||||||
# top_level_of: returns the top-level container of a child widget
|
# top_level_of: returns the top-level container of a child widget
|
||||||
w = QWidget(); child = QWidget(w)
|
w = QWidget()
|
||||||
|
child = QWidget(w)
|
||||||
assert wu.top_level_of(child) is w
|
assert wu.top_level_of(child) is w
|
||||||
assert wu.top_level_of(None) is None
|
assert wu.top_level_of(None) is None
|
||||||
print("[OK] top_level_of")
|
print("[OK] top_level_of")
|
||||||
|
|||||||
11949
tests/karen7
11949
tests/karen7
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@ N = 8 # number of servers
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
||||||
W = we_mod.Willexecutors
|
we_cls = we_mod.Willexecutors
|
||||||
|
|
||||||
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
||||||
def slow_get_info(url, we, **kwargs):
|
def slow_get_info(url, we, **kwargs):
|
||||||
@@ -43,8 +43,8 @@ def main():
|
|||||||
we["status"] = 200
|
we["status"] = 200
|
||||||
return we
|
return we
|
||||||
|
|
||||||
orig_get_info = W.get_info_task
|
orig_get_info = we_cls.get_info_task
|
||||||
W.get_info_task = staticmethod(slow_get_info)
|
we_cls.get_info_task = staticmethod(slow_get_info)
|
||||||
try:
|
try:
|
||||||
wes = {}
|
wes = {}
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -57,7 +57,7 @@ def main():
|
|||||||
seen.append((url, ok))
|
seen.append((url, ok))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
we_cls.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
||||||
@@ -81,15 +81,15 @@ def main():
|
|||||||
assert we["status"] == "KO", (url, we)
|
assert we["status"] == "KO", (url, we)
|
||||||
print("[OK] ping results written back into the willexecutors mapping")
|
print("[OK] ping results written back into the willexecutors mapping")
|
||||||
finally:
|
finally:
|
||||||
W.get_info_task = orig_get_info
|
we_cls.get_info_task = orig_get_info
|
||||||
|
|
||||||
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
||||||
def slow_push(we, **kwargs):
|
def slow_push(we, **kwargs):
|
||||||
time.sleep(SLOW)
|
time.sleep(SLOW)
|
||||||
return "fail" not in we["url"]
|
return "fail" not in we["url"]
|
||||||
|
|
||||||
orig_push = W.push_transactions_to_willexecutor
|
orig_push = we_cls.push_transactions_to_willexecutor
|
||||||
W.push_transactions_to_willexecutor = staticmethod(slow_push)
|
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push)
|
||||||
try:
|
try:
|
||||||
wes = {}
|
wes = {}
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -106,7 +106,7 @@ def main():
|
|||||||
pushed.append((url, ok))
|
pushed.append((url, ok))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = W.push_transactions_parallel(wes, on_each=on_each_push,
|
results = we_cls.push_transactions_parallel(wes, on_each=on_each_push,
|
||||||
max_workers=N)
|
max_workers=N)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@ def main():
|
|||||||
f"(sequential would be ~{sequential:.2f}s)")
|
f"(sequential would be ~{sequential:.2f}s)")
|
||||||
|
|
||||||
assert len(results) == N, results
|
assert len(results) == N, results
|
||||||
for url, (ok, exc) in results.items():
|
for url, (ok, _exc) in results.items():
|
||||||
assert ok == ("good" in url), (url, ok)
|
assert ok == ("good" in url), (url, ok)
|
||||||
print("[OK] push results correct for every server")
|
print("[OK] push results correct for every server")
|
||||||
finally:
|
finally:
|
||||||
W.push_transactions_to_willexecutor = orig_push
|
we_cls.push_transactions_to_willexecutor = orig_push
|
||||||
|
|
||||||
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
||||||
def hanging_push(we, **kwargs):
|
def hanging_push(we, **kwargs):
|
||||||
@@ -129,8 +129,8 @@ def main():
|
|||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
orig_push2 = W.push_transactions_to_willexecutor
|
orig_push2 = we_cls.push_transactions_to_willexecutor
|
||||||
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
we_cls.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
||||||
try:
|
try:
|
||||||
wes = {
|
wes = {
|
||||||
"https://fast.example": {
|
"https://fast.example": {
|
||||||
@@ -146,7 +146,7 @@ def main():
|
|||||||
return True
|
return True
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return True
|
return True
|
||||||
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
we_cls.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
||||||
|
|
||||||
timed_out = []
|
timed_out = []
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ def main():
|
|||||||
timed_out.append(url)
|
timed_out.append(url)
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
W.push_transactions_parallel(
|
we_cls.push_transactions_parallel(
|
||||||
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
||||||
)
|
)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -163,7 +163,7 @@ def main():
|
|||||||
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
||||||
f"hung server reported via on_timeout")
|
f"hung server reported via on_timeout")
|
||||||
finally:
|
finally:
|
||||||
W.push_transactions_to_willexecutor = orig_push2
|
we_cls.push_transactions_to_willexecutor = orig_push2
|
||||||
|
|
||||||
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
||||||
# The elapsed-time counter is driven by an on_tick callback called from the
|
# The elapsed-time counter is driven by an on_tick callback called from the
|
||||||
@@ -175,8 +175,8 @@ def main():
|
|||||||
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
||||||
return True
|
return True
|
||||||
|
|
||||||
orig_push3 = W.push_transactions_to_willexecutor
|
orig_push3 = we_cls.push_transactions_to_willexecutor
|
||||||
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
we_cls.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
||||||
try:
|
try:
|
||||||
wes = {
|
wes = {
|
||||||
"https://tick.example": {
|
"https://tick.example": {
|
||||||
@@ -191,7 +191,7 @@ def main():
|
|||||||
ticks.append(time.time())
|
ticks.append(time.time())
|
||||||
tick_threads.add(threading.current_thread())
|
tick_threads.add(threading.current_thread())
|
||||||
|
|
||||||
W.push_transactions_parallel(
|
we_cls.push_transactions_parallel(
|
||||||
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
||||||
)
|
)
|
||||||
# ~3s push with 0.5s ticks => at least a few ticks.
|
# ~3s push with 0.5s ticks => at least a few ticks.
|
||||||
@@ -202,7 +202,7 @@ def main():
|
|||||||
)
|
)
|
||||||
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
||||||
finally:
|
finally:
|
||||||
W.push_transactions_to_willexecutor = orig_push3
|
we_cls.push_transactions_to_willexecutor = orig_push3
|
||||||
|
|
||||||
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
||||||
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
||||||
@@ -214,8 +214,8 @@ def main():
|
|||||||
time.sleep(SLOW)
|
time.sleep(SLOW)
|
||||||
return {"tx": "ok"} if "good" in url else None
|
return {"tx": "ok"} if "good" in url else None
|
||||||
|
|
||||||
orig_check = W.check_transaction
|
orig_check = we_cls.check_transaction
|
||||||
W.check_transaction = staticmethod(slow_check)
|
we_cls.check_transaction = staticmethod(slow_check)
|
||||||
try:
|
try:
|
||||||
targets = []
|
targets = []
|
||||||
for i in range(N):
|
for i in range(N):
|
||||||
@@ -228,7 +228,7 @@ def main():
|
|||||||
checked.append((wid, res))
|
checked.append((wid, res))
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
results = W.check_transactions_parallel(
|
results = we_cls.check_transactions_parallel(
|
||||||
targets, on_each=on_each_check, max_workers=N
|
targets, on_each=on_each_check, max_workers=N
|
||||||
)
|
)
|
||||||
elapsed = time.time() - start
|
elapsed = time.time() - start
|
||||||
@@ -239,7 +239,7 @@ def main():
|
|||||||
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
||||||
f"(sequential would be ~{sequential:.2f}s)")
|
f"(sequential would be ~{sequential:.2f}s)")
|
||||||
finally:
|
finally:
|
||||||
W.check_transaction = orig_check
|
we_cls.check_transaction = orig_check
|
||||||
|
|
||||||
# 2d-bis) global deadline + on_tick from the calling thread
|
# 2d-bis) global deadline + on_tick from the calling thread
|
||||||
def hanging_check(txid, url, **kwargs):
|
def hanging_check(txid, url, **kwargs):
|
||||||
@@ -248,8 +248,8 @@ def main():
|
|||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
return {"tx": "ok"}
|
return {"tx": "ok"}
|
||||||
|
|
||||||
orig_check2 = W.check_transaction
|
orig_check2 = we_cls.check_transaction
|
||||||
W.check_transaction = staticmethod(hanging_check)
|
we_cls.check_transaction = staticmethod(hanging_check)
|
||||||
try:
|
try:
|
||||||
targets = [
|
targets = [
|
||||||
("idf", "https://fast.example"),
|
("idf", "https://fast.example"),
|
||||||
@@ -268,7 +268,7 @@ def main():
|
|||||||
tick_threads.add(threading.current_thread())
|
tick_threads.add(threading.current_thread())
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
W.check_transactions_parallel(
|
we_cls.check_transactions_parallel(
|
||||||
targets, max_workers=2, deadline=2.0,
|
targets, max_workers=2, deadline=2.0,
|
||||||
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
||||||
tick_interval=0.5,
|
tick_interval=0.5,
|
||||||
@@ -282,7 +282,7 @@ def main():
|
|||||||
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
||||||
f"fired {len(ticks)}x from the calling thread")
|
f"fired {len(ticks)}x from the calling thread")
|
||||||
finally:
|
finally:
|
||||||
W.check_transaction = orig_check2
|
we_cls.check_transaction = orig_check2
|
||||||
|
|
||||||
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
||||||
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import sys
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
# Same colors as BalBuildWillDialog
|
# Same colors as BalBuildWillDialog
|
||||||
COLOR_WARNING = "#cfa808"
|
COLOR_WARNING = "#cfa808"
|
||||||
|
|||||||
@@ -17,10 +17,15 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
|
||||||
)
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
COLOR_OK = "#05ad05"
|
COLOR_OK = "#05ad05"
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import sys
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
|
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
COLOR_ERROR = "#ff0000"
|
COLOR_ERROR = "#ff0000"
|
||||||
COLOR_OK = "#05ad05"
|
COLOR_OK = "#05ad05"
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
from PyQt6.QtCore import QSize # noqa: E402
|
||||||
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
|
|
||||||
QLabel,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
|
||||||
from PyQt6.QtCore import QSize, Qt # noqa: E402
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
|
||||||
"wizard.png")
|
"wizard.png")
|
||||||
|
|||||||
@@ -27,12 +27,19 @@ import os
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
from PyQt6.QtWidgets import ( # noqa: E402
|
|
||||||
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
|
|
||||||
QLineEdit, QSpinBox, QLabel,
|
|
||||||
)
|
|
||||||
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
|
||||||
from PyQt6.QtCore import Qt # noqa: E402
|
from PyQt6.QtCore import Qt # noqa: E402
|
||||||
|
from PyQt6.QtGui import QFontMetrics # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import ( # noqa: E402
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QToolButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _char_w():
|
def _char_w():
|
||||||
|
|||||||
792
tests/samanta7
Normal file
792
tests/samanta7
Normal file
File diff suppressed because one or more lines are too long
@@ -21,18 +21,21 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import copy
|
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.util import copy_structure
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
WillItem, Will,
|
HeirNotFoundException,
|
||||||
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
|
NoHeirsException,
|
||||||
TxFeesChangedException, WillExpiredException,
|
NotCompleteWillException,
|
||||||
|
TxFeesChangedException,
|
||||||
|
Will,
|
||||||
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
)
|
)
|
||||||
from bal.core.util import Util
|
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
@@ -55,7 +58,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
|
||||||
d = {
|
d = {
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(heirs),
|
"heirs": copy_structure(heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -64,7 +67,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"baltx_fees": TX_FEES,
|
"baltx_fees": TX_FEES,
|
||||||
}
|
}
|
||||||
item = WillItem(d, _id="willid_1")
|
item = WillItem(d, _id="willid_1")
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
# Force the locktime frozen "inside" the signed tx.
|
# Force the locktime frozen "inside" the signed tx.
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
if status_complete:
|
if status_complete:
|
||||||
@@ -115,7 +118,7 @@ def main():
|
|||||||
# Scenario 0: nothing changed -> should be coherent.
|
# Scenario 0: nothing changed -> should be coherent.
|
||||||
heirs = {"alice": ["addr_alice", 5000, same_lt]}
|
heirs = {"alice": ["addr_alice", 5000, same_lt]}
|
||||||
_run("0. nothing changed",
|
_run("0. nothing changed",
|
||||||
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
|
will_heirs=heirs, current_heirs=copy_structure(heirs),
|
||||||
tx_locktime=base_lt, check_date=0)
|
tx_locktime=base_lt, check_date=0)
|
||||||
|
|
||||||
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
|
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
|
||||||
|
|||||||
@@ -26,30 +26,28 @@ def main():
|
|||||||
from PyQt6.QtWidgets import QApplication # noqa
|
from PyQt6.QtWidgets import QApplication # noqa
|
||||||
_app = QApplication.instance() or QApplication([])
|
_app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
results = {}
|
|
||||||
|
|
||||||
# 1) Core modules import (these must be GUI-free).
|
# 1) Core modules import (these must be GUI-free).
|
||||||
bal = imp_core("bal", "core.plugin_base")
|
bal = imp_core("bal", "core.plugin_base")
|
||||||
util = imp_core("util", "core.util")
|
util = imp_core("util", "core.util")
|
||||||
heirs = imp_core("heirs", "core.heirs")
|
heirs = imp_core("heirs", "core.heirs")
|
||||||
will = imp_core("will", "core.will")
|
will = imp_core("will", "core.will")
|
||||||
we = imp_core("willexecutors", "core.willexecutors")
|
_we = imp_core("willexecutors", "core.willexecutors")
|
||||||
|
|
||||||
# 2) GUI module imports.
|
# 2) GUI module imports.
|
||||||
qt = imp_gui()
|
qt = imp_gui()
|
||||||
|
|
||||||
# 3) Behaviour checks (pure logic, must be identical across versions).
|
# 3) Behaviour checks (pure logic, must be identical across versions).
|
||||||
BalTimestamp = bal.BalTimestamp
|
bal_timestamp = bal.BalTimestamp
|
||||||
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
assert bal_timestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
|
||||||
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
assert bal_timestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
|
||||||
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
|
assert str(bal_timestamp("7d")) == "7d", "BalTimestamp str"
|
||||||
|
|
||||||
Util = util.Util
|
util_cls = util.Util
|
||||||
assert Util.is_perc("50%") is True
|
assert util_cls.is_perc("50%") is True
|
||||||
assert Util.is_perc("100") is False
|
assert util_cls.is_perc("100") is False
|
||||||
assert Util.text_to_hex("BAL") == "42414c"
|
assert util_cls.text_to_hex("BAL") == "42414c"
|
||||||
assert Util.hex_to_text("42414c") == "BAL"
|
assert util_cls.hex_to_text("42414c") == "BAL"
|
||||||
assert Util.int_locktime(days=1) == 86400
|
assert util_cls.int_locktime(days=1) == 86400
|
||||||
|
|
||||||
# heirs constants must keep the same column layout (very delicate!)
|
# heirs constants must keep the same column layout (very delicate!)
|
||||||
assert heirs.HEIR_ADDRESS == 0
|
assert heirs.HEIR_ADDRESS == 0
|
||||||
|
|||||||
@@ -27,19 +27,19 @@ Run:
|
|||||||
tests/test_anticipate_manual_locktime.py -q
|
tests/test_anticipate_manual_locktime.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import copy
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
import pytest # noqa: E402
|
import pytest # noqa: E402 # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
|
from bal.core.util import copy_structure # noqa: E402
|
||||||
from bal.core.will import ( # noqa: E402
|
from bal.core.will import ( # noqa: E402
|
||||||
WillItem,
|
|
||||||
Will,
|
|
||||||
NotCompleteWillException,
|
NotCompleteWillException,
|
||||||
|
Will,
|
||||||
WillExpiredException,
|
WillExpiredException,
|
||||||
|
WillItem,
|
||||||
)
|
)
|
||||||
|
|
||||||
# A valid serialized tx (1 input + 1 output, version 2).
|
# A valid serialized tx (1 input + 1 output, version 2).
|
||||||
@@ -70,7 +70,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"""
|
"""
|
||||||
d = {
|
d = {
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(heirs),
|
"heirs": copy_structure(heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -79,7 +79,7 @@ def _make_will_item(heirs, tx_locktime, status_complete=False):
|
|||||||
"baltx_fees": TX_FEES,
|
"baltx_fees": TX_FEES,
|
||||||
}
|
}
|
||||||
item = WillItem(d, _id="willid_1")
|
item = WillItem(d, _id="willid_1")
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
if status_complete:
|
if status_complete:
|
||||||
item.set_status("COMPLETE", True)
|
item.set_status("COMPLETE", True)
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ whether a fix is needed. Run:
|
|||||||
python3 -m pytest tests/test_anticipate_past_locktime.py -q
|
python3 -m pytest tests/test_anticipate_past_locktime.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
from bal.core.util import LOCKTIME_THRESHOLD, Util
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
570
tests/test_auto_rebuild_on_new_tx.py
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the "Rebuild automatically on new transactions" (AUTO_REBUILD)
|
||||||
|
feature.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
|
||||||
|
* the persisted ``bal_auto_rebuild`` configuration key exists and defaults
|
||||||
|
to OFF (False), and can be enabled and read back;
|
||||||
|
* the event wiring: ``Plugin._wallet_activity`` schedules the rebuild only
|
||||||
|
for the matching wallet and only when the setting is enabled;
|
||||||
|
* ``BalWindow.schedule_auto_rebuild`` debounces through ``QTimer`` and the
|
||||||
|
re-entrancy / cooldown guards;
|
||||||
|
* ``BalWindow.maybe_auto_rebuild`` reproduces the wizard's close-time flow:
|
||||||
|
- no-op when the will is still valid;
|
||||||
|
- rebuild + sign + push when a new UTXO invalidates the will (no on-chain
|
||||||
|
invalidation, the rebuilt tx is anticipated to mine before the old);
|
||||||
|
- on-chain invalidation when the check-alive threshold is already in the
|
||||||
|
past (CheckAliveError);
|
||||||
|
- on-chain invalidation when the will is already expired;
|
||||||
|
- on-chain invalidation when the anticipated locktime would fall before
|
||||||
|
the check-alive threshold (and no sign/push in that case).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_auto_rebuild_on_new_tx.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from electrum import bitcoin, crypto # noqa: E402
|
||||||
|
from electrum.descriptor import parse_descriptor # noqa: E402
|
||||||
|
from electrum.transaction import ( # noqa: E402
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
TxOutpoint,
|
||||||
|
)
|
||||||
|
from electrum.util import bfh # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import bal.gui.qt.window as window_mod # noqa: E402
|
||||||
|
from bal.core.heirs import Heirs # noqa: E402
|
||||||
|
from bal.core.plugin_base import BalConfig, BalPlugin # noqa: E402
|
||||||
|
from bal.core.util import Util # noqa: E402
|
||||||
|
from bal.core.will import Will # noqa: E402
|
||||||
|
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||||
|
from bal.gui.qt.plugin import Plugin # noqa: E402
|
||||||
|
from bal.gui.qt.window import BalWindow # noqa: E402
|
||||||
|
|
||||||
|
CONFIG_KEY = "bal_auto_rebuild"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
PRIVKEY = bytes(range(32))
|
||||||
|
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||||
|
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||||
|
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||||
|
FUNDING_SATOSHIS = 500000
|
||||||
|
|
||||||
|
|
||||||
|
def make_funding_input(prevout_hex="11" * 32):
|
||||||
|
"""Return a fake wallet UTXO spendable by the will."""
|
||||||
|
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||||
|
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||||
|
ADDRESS, FUNDING_SATOSHIS
|
||||||
|
)
|
||||||
|
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
utxo._TxInput__scriptpubkey = SCRIPT
|
||||||
|
utxo._TxInput__address = ADDRESS
|
||||||
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
|
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 get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self, utxos):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.adb = None
|
||||||
|
self.network = None
|
||||||
|
self._utxos = list(utxos)
|
||||||
|
self._dust = 546
|
||||||
|
self._change_addresses = [ADDRESS]
|
||||||
|
self.labels = {}
|
||||||
|
self.save_db_calls = 0
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
self.save_db_calls += 1
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_label(self, txid, label):
|
||||||
|
self.labels[txid] = label
|
||||||
|
|
||||||
|
def get_all_labels(self):
|
||||||
|
return dict(self.labels)
|
||||||
|
|
||||||
|
def get_label_for_txid(self, txid):
|
||||||
|
return self.labels.get(txid, "")
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return list(self._utxos)
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return self._change_addresses
|
||||||
|
|
||||||
|
def add_input_info(self, txin, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_output_info(self, txout, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
class _TxInfo:
|
||||||
|
def __init__(self):
|
||||||
|
class _MinedStatus:
|
||||||
|
def height(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.tx_mined_status = _MinedStatus()
|
||||||
|
|
||||||
|
return _TxInfo()
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||||
|
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||||
|
for txin in tx.inputs():
|
||||||
|
if txin.script_descriptor is None:
|
||||||
|
txin.script_descriptor = descriptor
|
||||||
|
if txin.value_sats() is None:
|
||||||
|
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
tx.sign({PUBKEY: PRIVKEY})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfig:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
|
||||||
|
|
||||||
|
def electrum_path(self):
|
||||||
|
return self._tmpdir
|
||||||
|
|
||||||
|
def user_dir(self):
|
||||||
|
return self._tmpdir
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def set_key(self, key, value, save=True):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow:
|
||||||
|
def __init__(self, wallet):
|
||||||
|
self.wallet = wallet
|
||||||
|
self.messages = []
|
||||||
|
self.warnings = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
def get_decimal_point(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def show_message(self, text):
|
||||||
|
self.messages.append(str(text))
|
||||||
|
|
||||||
|
def show_warning(self, text, parent=None, title=None):
|
||||||
|
self.warnings.append(str(text))
|
||||||
|
|
||||||
|
def show_error(self, text):
|
||||||
|
self.errors.append(str(text))
|
||||||
|
|
||||||
|
def show_critical(self, text):
|
||||||
|
self.errors.append(str(text))
|
||||||
|
|
||||||
|
def update_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(utxos=None):
|
||||||
|
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
|
||||||
|
utxos = [make_funding_input()] if utxos is None else utxos
|
||||||
|
config = FakeConfig()
|
||||||
|
wallet = FakeWallet(utxos)
|
||||||
|
window = FakeWindow(wallet)
|
||||||
|
|
||||||
|
plugin = BalPlugin(None, config, "bal")
|
||||||
|
plugin.get_window_title = lambda title: str(title)
|
||||||
|
plugin.get_decimal_point = window.get_decimal_point
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
plugin.AUTO_REBUILD.set(True)
|
||||||
|
|
||||||
|
ctl = BalWindow.__new__(BalWindow)
|
||||||
|
ctl.bal_plugin = plugin
|
||||||
|
ctl.window = window
|
||||||
|
ctl.wallet = wallet
|
||||||
|
ctl.will = {}
|
||||||
|
ctl.willitems = {}
|
||||||
|
ctl.willexecutors = {}
|
||||||
|
ctl.will_settings = plugin.WILL_SETTINGS.get()
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
ctl.heirs = Heirs(wallet)
|
||||||
|
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
|
||||||
|
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
|
||||||
|
ctl.no_willexecutor = True
|
||||||
|
ctl.disable_plugin = False
|
||||||
|
ctl.ok = True
|
||||||
|
ctl.update_all = lambda: None
|
||||||
|
ctl._schedule_history_refresh = lambda: None
|
||||||
|
ctl._auto_rebuild_running = False
|
||||||
|
ctl._auto_rebuild_cooldown_until = 0.0
|
||||||
|
return ctl
|
||||||
|
|
||||||
|
|
||||||
|
def _no_willexecutors():
|
||||||
|
"""Force an empty will-executor list (offline tests)."""
|
||||||
|
return mock.patch.object(
|
||||||
|
Willexecutors,
|
||||||
|
"get_willexecutors",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single(controller):
|
||||||
|
"""Return (txid, WillItem) for the controller's single will item."""
|
||||||
|
assert len(controller.willitems) == 1, controller.willitems
|
||||||
|
return next(iter(controller.willitems.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_spending(controller, *prevout_hexes):
|
||||||
|
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||||
|
wanted = sorted(h for h in prevout_hexes)
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in controller.willitems.values()
|
||||||
|
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||||
|
]
|
||||||
|
assert len(items) == 1, controller.willitems
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Config key
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_auto_rebuild_config_defaults_off():
|
||||||
|
"""bal_auto_rebuild must default to OFF (False) when not yet stored."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||||
|
assert rebuild.get() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_config_can_be_enabled():
|
||||||
|
"""Once enabled and persisted, bal_auto_rebuild reads back True."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
rebuild = BalConfig(cfg, CONFIG_KEY, False)
|
||||||
|
rebuild.set(True)
|
||||||
|
assert rebuild.get() is True
|
||||||
|
assert BalConfig(cfg, CONFIG_KEY, False).get() is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Event wiring (Plugin._wallet_activity)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_wallet_activity_schedules_only_matching_wallet():
|
||||||
|
plugin = Plugin.__new__(Plugin)
|
||||||
|
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, True)
|
||||||
|
wallet_a = FakeWallet([make_funding_input()])
|
||||||
|
wallet_b = FakeWallet([make_funding_input()])
|
||||||
|
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
class _Win:
|
||||||
|
wallet = wallet_a
|
||||||
|
ok = True
|
||||||
|
disable_plugin = False
|
||||||
|
|
||||||
|
def schedule_auto_rebuild(self):
|
||||||
|
scheduled.append(self)
|
||||||
|
|
||||||
|
win = _Win()
|
||||||
|
plugin.bal_windows = {"a": win}
|
||||||
|
|
||||||
|
plugin._wallet_activity(wallet_b)
|
||||||
|
assert scheduled == [], "a different wallet must not schedule a rebuild"
|
||||||
|
|
||||||
|
plugin._wallet_activity(wallet_a)
|
||||||
|
assert scheduled == [win], "the matching wallet must schedule a rebuild"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wallet_activity_skips_when_disabled():
|
||||||
|
plugin = Plugin.__new__(Plugin)
|
||||||
|
plugin.AUTO_REBUILD = BalConfig(FakeConfig(), CONFIG_KEY, False)
|
||||||
|
wallet_obj = FakeWallet([make_funding_input()])
|
||||||
|
|
||||||
|
scheduled = []
|
||||||
|
|
||||||
|
class _Win:
|
||||||
|
wallet = wallet_obj
|
||||||
|
ok = True
|
||||||
|
disable_plugin = False
|
||||||
|
|
||||||
|
def schedule_auto_rebuild(self):
|
||||||
|
scheduled.append(self)
|
||||||
|
|
||||||
|
plugin.bal_windows = {"a": _Win()}
|
||||||
|
|
||||||
|
plugin._wallet_activity(wallet_obj)
|
||||||
|
assert scheduled == [], "AUTO_REBUILD off must not schedule anything"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Scheduling / guards
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_schedule_auto_rebuild_debounces():
|
||||||
|
ctl = make_controller()
|
||||||
|
with mock.patch.object(window_mod.QTimer, "singleShot") as single_shot:
|
||||||
|
ctl.schedule_auto_rebuild()
|
||||||
|
single_shot.assert_called_once_with(
|
||||||
|
ctl._AUTO_REBUILD_DEBOUNCE_MS, ctl._run_auto_rebuild
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_guards():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
assert ctl._auto_rebuild_allowed() is True
|
||||||
|
# Re-entrancy guard.
|
||||||
|
ctl._auto_rebuild_running = True
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
ctl._auto_rebuild_running = False
|
||||||
|
# Cooldown guard.
|
||||||
|
ctl._auto_rebuild_cooldown_until = time.time() + 100
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
ctl._auto_rebuild_cooldown_until = 0.0
|
||||||
|
assert ctl._auto_rebuild_allowed() is True
|
||||||
|
# Disabled / inactive guards.
|
||||||
|
ctl.disable_plugin = True
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
ctl.disable_plugin = False
|
||||||
|
ctl.ok = False
|
||||||
|
assert ctl._auto_rebuild_allowed() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_auto_rebuild_spawns_worker_when_allowed():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
|
||||||
|
started = []
|
||||||
|
|
||||||
|
class FakeThread:
|
||||||
|
def __init__(self, target, daemon=None):
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
started.append(self.target)
|
||||||
|
|
||||||
|
with mock.patch.object(window_mod.threading, "Thread", FakeThread):
|
||||||
|
ctl._run_auto_rebuild()
|
||||||
|
assert len(started) == 1, "the worker thread must be spawned"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# maybe_auto_rebuild behaviour
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_when_disabled():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
ctl.bal_plugin.AUTO_REBUILD.set(False)
|
||||||
|
txid_before, _ = _single(ctl)
|
||||||
|
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
txid_after, _ = _single(ctl)
|
||||||
|
assert txid_after == txid_before, "disabled flow must not touch the will"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_without_will():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
assert not ctl.willitems
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_when_will_valid():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid_before, _ = _single(ctl)
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "_auto_sign_save_push"
|
||||||
|
) as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
txid_after, _ = _single(ctl)
|
||||||
|
assert txid_after == txid_before, "a valid will must not be rebuilt"
|
||||||
|
inv.assert_not_called()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
# A relative delivery recipe keeps the will coherent after the rebuild
|
||||||
|
# anticipates the locktime by one day (an absolute recipe would read the
|
||||||
|
# anticipated tx as a postpone, see check_willexecutors_and_heirs).
|
||||||
|
ctl.will_settings["locktime"] = "1y"
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, old_item = _single(ctl)
|
||||||
|
old_locktime = int(old_item.tx.locktime)
|
||||||
|
|
||||||
|
# An incoming payment adds a second UTXO -> the will no longer covers
|
||||||
|
# the whole wallet (NotCompleteWillException).
|
||||||
|
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "push_transactions_to_willexecutors"
|
||||||
|
) as push, mock.patch.object(ctl, "_save_will_to_history") as history:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True, "a stale will must be rebuilt"
|
||||||
|
assert inv.call_count == 0, "a plain rebuild must not invalidate on-chain"
|
||||||
|
push.assert_called_once()
|
||||||
|
history.assert_called_once()
|
||||||
|
|
||||||
|
# The rebuilt will now spends BOTH wallet UTXOs (BAL keeps the previous
|
||||||
|
# single-input transaction alongside it in the will).
|
||||||
|
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
# The new locktime must be at most the old one, so the new tx can be mined
|
||||||
|
# before the previous will.
|
||||||
|
assert int(new_item.tx.locktime) <= old_locktime
|
||||||
|
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||||
|
assert new_item.get_status("VALID")
|
||||||
|
|
||||||
|
# The rebuilt will is still valid now: no further work.
|
||||||
|
assert ctl.check_will() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_threshold_passed():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
# ADVANCED mode with a check-alive threshold already in the past.
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
ctl.will_settings["threshold"] = int(time.time()) - 3600
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "_auto_sign_save_push"
|
||||||
|
) as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
inv.assert_called_once()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
# Move the frozen delivery date into the past: "too late to
|
||||||
|
# anticipate" -> the old will must be invalidated on-chain.
|
||||||
|
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||||
|
|
||||||
|
with mock.patch.object(ctl, "_auto_invalidate_will") as inv, mock.patch.object(
|
||||||
|
ctl, "_auto_sign_save_push"
|
||||||
|
) as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
inv.assert_called_once()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
now = time.time()
|
||||||
|
delivery = int(now + 3 * 86400)
|
||||||
|
ctl.will_settings["locktime"] = delivery
|
||||||
|
# ADVANCED mode: the check-alive threshold sits 12h before delivery, so
|
||||||
|
# an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
ctl.will_settings["threshold"] = delivery - 12 * 3600
|
||||||
|
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, _ = _single(ctl)
|
||||||
|
ctl.wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
# The rebuild itself anticipates the delivery date by one day ONLY when
|
||||||
|
# the rebuilt transactions keep the same real amounts (Will.check_anticipate,
|
||||||
|
# same coins + same heirs). Real amounts are re-computed against the
|
||||||
|
# wallet balance, so a new UTXO normally changes them and the rebuilt
|
||||||
|
# will keeps the old locktime. Force the anticipating branch here to
|
||||||
|
# exercise the "anticipated locktime crosses the threshold" handling.
|
||||||
|
with mock.patch.object(
|
||||||
|
Will, "check_anticipate", return_value=delivery - 86400
|
||||||
|
):
|
||||||
|
with mock.patch.object(
|
||||||
|
ctl, "_auto_invalidate_will"
|
||||||
|
) as inv, mock.patch.object(ctl, "_auto_sign_save_push") as sign:
|
||||||
|
result = ctl.maybe_auto_rebuild()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
inv.assert_called_once(), (
|
||||||
|
"an anticipated locktime below the threshold must invalidate on-chain"
|
||||||
|
)
|
||||||
|
sign.assert_not_called(), (
|
||||||
|
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||||
|
"(the wizard stops and waits for the invalidation to confirm)"
|
||||||
|
)
|
||||||
|
new_item = _item_spending(ctl, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||||
|
"the rebuilt locktime must be anticipated by one day"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
|
for fn in tests:
|
||||||
|
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||||
|
fn()
|
||||||
|
print("OK")
|
||||||
|
print(f"\n{len(tests)} tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_run_all()
|
||||||
339
tests/test_cli_autorebuild.py
Normal file
339
tests/test_cli_autorebuild.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the headless auto-rebuild flow (``bal_will_autorebuild``).
|
||||||
|
|
||||||
|
The CLI equivalent of the GUI AUTO_REBUILD feature:
|
||||||
|
``BalController.auto_rebuild`` runs the wizard's close-time flow in a single
|
||||||
|
call. Everything is exercised offline against a fake signing wallet (the same
|
||||||
|
fixtures the GUI tests use), so no wallet, network or Qt is needed.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
|
||||||
|
* no-op when the will is still valid (``valid``);
|
||||||
|
* rebuild + sign + push when a new UTXO invalidates the will (``rebuilt``,
|
||||||
|
no on-chain invalidation: the rebuilt tx is anticipated to mine before
|
||||||
|
the old one);
|
||||||
|
* ``needs_signing`` when the wallet is encrypted;
|
||||||
|
* on-chain invalidation when the will is already expired (``expired``);
|
||||||
|
* on-chain invalidation when the anticipated locktime crosses the check-alive
|
||||||
|
threshold (``anticipation_crossed``) - and no sign/push in that case.
|
||||||
|
|
||||||
|
The ``no_heirs`` and ``threshold_passed`` paths live in
|
||||||
|
``test_cli_controller_offline.py``.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 tests/test_cli_autorebuild.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum import bitcoin, crypto
|
||||||
|
from electrum.descriptor import parse_descriptor
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
from electrum.transaction import PartialTxInput, PartialTxOutput, TxOutpoint
|
||||||
|
from electrum.util import bfh
|
||||||
|
|
||||||
|
from bal.cli.controller import BalController
|
||||||
|
from bal.core.will import Will
|
||||||
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
PRIVKEY = bytes(range(32))
|
||||||
|
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||||
|
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||||
|
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||||
|
FUNDING_SATOSHIS = 500000
|
||||||
|
|
||||||
|
|
||||||
|
def make_funding_input(prevout_hex="11" * 32):
|
||||||
|
"""Return a fake wallet UTXO spendable by the will."""
|
||||||
|
utxo = PartialTxInput(prevout=TxOutpoint(bfh(prevout_hex), 0))
|
||||||
|
utxo.witness_utxo = PartialTxOutput.from_address_and_value(
|
||||||
|
ADDRESS, FUNDING_SATOSHIS
|
||||||
|
)
|
||||||
|
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
utxo._TxInput__scriptpubkey = SCRIPT
|
||||||
|
utxo._TxInput__address = ADDRESS
|
||||||
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
|
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 get_dict(self, key):
|
||||||
|
return self._data.setdefault(key, {})
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_transaction(self, tx, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self, utxos, encrypted=False):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.adb = None
|
||||||
|
self.network = None
|
||||||
|
self._utxos = list(utxos)
|
||||||
|
self._dust = 546
|
||||||
|
self._change_addresses = [ADDRESS]
|
||||||
|
self._encrypted = encrypted
|
||||||
|
self.labels = {}
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return self._encrypted
|
||||||
|
|
||||||
|
def set_label(self, txid, label):
|
||||||
|
self.labels[txid] = label
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return list(self._utxos)
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return self._change_addresses
|
||||||
|
|
||||||
|
def add_input_info(self, txin, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_output_info(self, txout, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
class _TxInfo:
|
||||||
|
def __init__(self):
|
||||||
|
class _MinedStatus:
|
||||||
|
def height(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.tx_mined_status = _MinedStatus()
|
||||||
|
|
||||||
|
return _TxInfo()
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||||
|
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||||
|
for txin in tx.inputs():
|
||||||
|
if txin.script_descriptor is None:
|
||||||
|
txin.script_descriptor = descriptor
|
||||||
|
if txin.value_sats() is None:
|
||||||
|
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
tx.sign({PUBKEY: PRIVKEY})
|
||||||
|
|
||||||
|
|
||||||
|
class _Plugin:
|
||||||
|
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_autorebuild_")
|
||||||
|
from bal.cli.plugin import Plugin as RealPlugin
|
||||||
|
|
||||||
|
self.config = SimpleConfig(
|
||||||
|
{"electrum_path": self.tmpdir},
|
||||||
|
read_user_config_function=lambda path: {},
|
||||||
|
)
|
||||||
|
self.plugin = RealPlugin(None, self.config, "bal")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.plugin
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_willexecutors():
|
||||||
|
"""Force an empty will-executor list (offline tests)."""
|
||||||
|
return mock.patch.object(
|
||||||
|
Willexecutors,
|
||||||
|
"get_willexecutors",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_controller(plugin, wallet):
|
||||||
|
c = BalController(plugin, wallet)
|
||||||
|
c.will_settings["locktime"] = "1y"
|
||||||
|
c.heirs_add("alice", ADDRESS, "100000")
|
||||||
|
c.heirs_add("bob", ADDRESS, "100%")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def _single(controller):
|
||||||
|
"""Return (txid, WillItem) for the controller's single will item."""
|
||||||
|
assert len(controller.willitems) == 1, controller.willitems
|
||||||
|
return next(iter(controller.willitems.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_spending(controller, *prevout_hexes):
|
||||||
|
"""Return the will item whose tx spends exactly the given prevouts."""
|
||||||
|
wanted = sorted(h for h in prevout_hexes)
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in controller.willitems.values()
|
||||||
|
if sorted(i.prevout.txid.hex() for i in item.tx.inputs()) == wanted
|
||||||
|
]
|
||||||
|
assert len(items) == 1, controller.willitems
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# auto_rebuild behaviour
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_auto_rebuild_noop_when_will_valid():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
txid_before, _ = _single(c)
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "valid", result
|
||||||
|
assert next(iter(c.willitems)) == txid_before, (
|
||||||
|
"a valid will must not be rebuilt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_rebuilds_and_pushes_on_new_utxo():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
old_txid, old_item = _single(c)
|
||||||
|
old_locktime = int(old_item.tx.locktime)
|
||||||
|
|
||||||
|
# An incoming payment adds a second UTXO -> the will no longer
|
||||||
|
# covers the whole wallet (NotCompleteWillException).
|
||||||
|
wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "rebuilt", result
|
||||||
|
assert result["push"] == {}
|
||||||
|
|
||||||
|
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
assert int(new_item.tx.locktime) <= old_locktime, (
|
||||||
|
"the rebuilt tx must be anticipatable before the old will"
|
||||||
|
)
|
||||||
|
assert new_item.get_status("COMPLETE"), "passwordless rebuild must sign"
|
||||||
|
assert new_item.get_status("VALID")
|
||||||
|
|
||||||
|
# The rebuilt will is still valid now: no further work.
|
||||||
|
assert c.check_will() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_encrypted_wallet_requires_manual_signing():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()], encrypted=True)
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "needs_signing", result
|
||||||
|
assert result["will"]["count"] == 2
|
||||||
|
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||||
|
"an encrypted wallet must never be signed without the password"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_locktime_expired():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.prepare_will()
|
||||||
|
_, item = _single(c)
|
||||||
|
# Move the frozen delivery date into the past: "too late to
|
||||||
|
# anticipate" -> the old will must be invalidated on-chain.
|
||||||
|
item.tx.locktime = int(time.time()) - 2 * 86400
|
||||||
|
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "invalidated", result
|
||||||
|
assert result["reason"] == "expired"
|
||||||
|
assert result["invalidation_tx"]["txid"] is not None
|
||||||
|
assert result["invalidation_tx"]["tx"]
|
||||||
|
assert not any(w.get_status("COMPLETE") for w in c.willitems.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_invalidates_when_anticipation_crosses_threshold():
|
||||||
|
with _no_willexecutors():
|
||||||
|
with _Plugin() as plugin:
|
||||||
|
now = time.time()
|
||||||
|
delivery = int(now + 3 * 86400)
|
||||||
|
# ADVANCED mode: the check-alive threshold sits 12h before delivery,
|
||||||
|
# so an anticipated (delivery - 1 day) locktime falls BEFORE it.
|
||||||
|
plugin.USER_TYPE.set("advanced")
|
||||||
|
wallet = FakeWallet([make_funding_input()])
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
c = _make_controller(plugin, wallet)
|
||||||
|
c.will_settings["locktime"] = delivery
|
||||||
|
c.will_settings["threshold"] = delivery - 12 * 3600
|
||||||
|
c.prepare_will()
|
||||||
|
old_txid, _ = _single(c)
|
||||||
|
wallet._utxos.append(make_funding_input("22" * 32))
|
||||||
|
|
||||||
|
# Force the anticipating branch (see the GUI test for the rationale:
|
||||||
|
# with a new UTXO the real amounts change, so the natural rebuild
|
||||||
|
# keeps the old locktime).
|
||||||
|
with mock.patch.object(
|
||||||
|
Will, "check_anticipate", return_value=delivery - 86400
|
||||||
|
):
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
|
||||||
|
assert result["result"] == "invalidated", result
|
||||||
|
assert result["reason"] == "anticipation_crossed"
|
||||||
|
assert not any(w.get_status("COMPLETE") for w in c.willitems.values()), (
|
||||||
|
"after an invalidation the rebuilt will must NOT be signed/pushed "
|
||||||
|
"(the wizard stops and waits for the invalidation to confirm)"
|
||||||
|
)
|
||||||
|
new_item = _item_spending(c, "11" * 32, "22" * 32)
|
||||||
|
assert new_item.tx.txid() != old_txid, "the rebuilt will must replace the old tx"
|
||||||
|
assert int(new_item.tx.locktime) == delivery - 86400, (
|
||||||
|
"the rebuilt locktime must be anticipated by one day"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
|
for fn in tests:
|
||||||
|
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||||
|
fn()
|
||||||
|
print("OK")
|
||||||
|
print(f"\n{len(tests)} tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_run_all()
|
||||||
150
tests/test_cli_commands_registered.py
Normal file
150
tests/test_cli_commands_registered.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Test: BAL plugin CLI commands are registered with Electrum.
|
||||||
|
|
||||||
|
Verifies that importing the plugin through Electrum's own plugin loader
|
||||||
|
(``Plugins(config, cmd_only=True)``, the exact code path ``run_electrum`` uses
|
||||||
|
to pre-parse the command line) registers every ``bal_*`` command with
|
||||||
|
``electrum.commands`` (``known_commands`` + the ``Commands`` class).
|
||||||
|
|
||||||
|
It also asserts the basic contract enforced by ``plugin_command``: each command
|
||||||
|
is a coroutine and carries the expected flags (all ``bal_*`` commands require a
|
||||||
|
daemon/network, i.e. the ``'n'`` flag; the wallet-bound ones the ``'w'`` flag;
|
||||||
|
signing also ``'p'``).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 tests/test_cli_commands_registered.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from electrum import commands as electrum_commands
|
||||||
|
from electrum.plugin import Plugins
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
|
||||||
|
# The full command table lives in PLAN_CMDLINE_PLUGIN.md section 6; new commands
|
||||||
|
# added in later phases must be appended here so the registration test keeps
|
||||||
|
# proving the whole list is wired up.
|
||||||
|
EXPECTED_COMMANDS = {
|
||||||
|
# Settings (no wallet required)
|
||||||
|
"bal_settings_list": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
"bal_settings_get": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
"bal_settings_set": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
"bal_settings_reset": {
|
||||||
|
"requires_network": True,
|
||||||
|
"requires_wallet": False,
|
||||||
|
"requires_password": False,
|
||||||
|
},
|
||||||
|
# Heirs
|
||||||
|
"bal_heirs_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_heirs_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
# Will-Executors
|
||||||
|
"bal_willexecutors_list": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_show": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_add": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_update": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_select": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_delete": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_ping": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_download": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_import": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_willexecutors_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
# Will
|
||||||
|
"bal_will_status": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_check": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_prepare": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_autorebuild": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_sign": {"requires_network": True, "requires_wallet": True, "requires_password": True},
|
||||||
|
"bal_will_broadcast": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_export": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_import_merge": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_invalidate": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
"bal_will_check_executor": {"requires_network": True, "requires_wallet": True, "requires_password": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _isolated_config(**overrides):
|
||||||
|
"""A throwaway 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. The bal plugin is
|
||||||
|
enabled because ``Plugins(cmd_only=True)`` skips any plugin that is not
|
||||||
|
explicitly enabled (electrum.plugin.Plugins.find_directory_plugins).
|
||||||
|
"""
|
||||||
|
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
|
||||||
|
opts.update(overrides)
|
||||||
|
cfg = SimpleConfig(opts)
|
||||||
|
cfg.enable_plugin("bal")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_commands_registered():
|
||||||
|
cfg = _isolated_config()
|
||||||
|
Plugins(cfg, cmd_only=True)
|
||||||
|
for name, flags in EXPECTED_COMMANDS.items():
|
||||||
|
assert name in electrum_commands.known_commands, f"{name} not registered"
|
||||||
|
cmd = electrum_commands.known_commands[name]
|
||||||
|
assert cmd.name == name
|
||||||
|
assert cmd.requires_network is flags["requires_network"]
|
||||||
|
assert cmd.requires_wallet is flags["requires_wallet"]
|
||||||
|
assert cmd.requires_password is flags["requires_password"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_commands_are_coroutines():
|
||||||
|
cfg = _isolated_config()
|
||||||
|
Plugins(cfg, cmd_only=True)
|
||||||
|
for name in EXPECTED_COMMANDS:
|
||||||
|
func = getattr(electrum_commands.Commands, name, None)
|
||||||
|
assert func is not None, f"{name} missing from Commands"
|
||||||
|
assert inspect.iscoroutinefunction(func), f"{name} is not a coroutine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_duplicate_registration():
|
||||||
|
"""Loading the plugin twice must not raise "Command name bal_... already
|
||||||
|
exists" (the guard in bal/__init__._register_cli_commands)."""
|
||||||
|
cfg = _isolated_config()
|
||||||
|
plugins = Plugins(cfg, cmd_only=True)
|
||||||
|
plugins.maybe_load_plugin_init_method("bal") # already imported -> no-op
|
||||||
|
for name in EXPECTED_COMMANDS:
|
||||||
|
assert name in electrum_commands.known_commands
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_docstrings_document_all_args():
|
||||||
|
"""Every parameter/option must carry an ``arg:TYPE:NAME:DESC`` line (the
|
||||||
|
CLI parser prints "undocumented argument ..." otherwise)."""
|
||||||
|
cfg = _isolated_config()
|
||||||
|
Plugins(cfg, cmd_only=True)
|
||||||
|
for name in EXPECTED_COMMANDS:
|
||||||
|
cmd = electrum_commands.known_commands[name]
|
||||||
|
for varname in list(cmd.params) + list(cmd.options):
|
||||||
|
if varname in ("wallet", "wallet_path", "plugin", "password"):
|
||||||
|
continue
|
||||||
|
assert varname in cmd.arg_descriptions, (
|
||||||
|
f"{name}: undocumented argument {varname}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All CLI registration tests passed")
|
||||||
288
tests/test_cli_controller_offline.py
Normal file
288
tests/test_cli_controller_offline.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
Offline tests for the headless ``bal.cli.controller.BalController``.
|
||||||
|
|
||||||
|
These run without a wallet, a network or Qt: the controller is exercised
|
||||||
|
against a ``FakeWallet`` plus a real ``bal.cli.plugin.Plugin`` backed by an
|
||||||
|
isolated in-memory ``SimpleConfig``. Only the flows that never touch the
|
||||||
|
network (settings/heirs/willexecutors CRUD, status snapshots, error mapping)
|
||||||
|
are covered here; build/sign/push flows need a live wallet and network and are
|
||||||
|
exercised by the group tests instead.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
python3 tests/test_cli_controller_offline.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum.simple_config import SimpleConfig
|
||||||
|
from electrum.util import UserFacingException
|
||||||
|
|
||||||
|
from bal.cli.controller import BalController
|
||||||
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
|
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
|
||||||
|
|
||||||
|
|
||||||
|
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 get_dict(self, key):
|
||||||
|
return self._data.setdefault(key, {})
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_transaction(self, tx, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.network = None
|
||||||
|
self.adb = None
|
||||||
|
self._dust = 500
|
||||||
|
|
||||||
|
def save_db(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_label(self, txid, text):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return [VALID_ADDRESS]
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin:
|
||||||
|
"""Real ``bal.cli.plugin.Plugin`` with an isolated config directory."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tmpdir = tempfile.mkdtemp(prefix="bal_cli_test_")
|
||||||
|
from bal.cli.plugin import Plugin as RealPlugin
|
||||||
|
|
||||||
|
self.config = SimpleConfig(
|
||||||
|
{"electrum_path": self.tmpdir},
|
||||||
|
read_user_config_function=lambda path: {},
|
||||||
|
)
|
||||||
|
self.plugin = RealPlugin(None, self.config, "bal")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self.plugin
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
shutil.rmtree(self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_controller(plugin):
|
||||||
|
return BalController(plugin, FakeWallet())
|
||||||
|
|
||||||
|
|
||||||
|
def test_controller_init_empty():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.willitems == {}
|
||||||
|
assert c.will == {}
|
||||||
|
assert c.heirs == {}
|
||||||
|
assert isinstance(c.will_settings, dict)
|
||||||
|
assert "baltx_fees" in c.will_settings
|
||||||
|
# Fresh config: no stored will-executors. On mainnet the default
|
||||||
|
# WILLEXECUTORS table is keyed by "mainnet" while chainname is
|
||||||
|
# "bitcoin", so nothing is injected either.
|
||||||
|
assert c.willexecutors == {}
|
||||||
|
assert c.no_willexecutor is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_roundtrip():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
listing = c.settings_list()
|
||||||
|
assert "BAL_TX_FEES" in listing or "TX_FEES" in listing
|
||||||
|
tx_key = "BAL_TX_FEES" if "BAL_TX_FEES" in listing else "TX_FEES"
|
||||||
|
assert c.settings_get(tx_key)["value"] == 100
|
||||||
|
|
||||||
|
c.settings_set("bal_tx_fees", "150")
|
||||||
|
assert c.settings_get("bal_tx_fees")["value"] == 150
|
||||||
|
assert c.settings_get("TX_FEES")["value"] == 150
|
||||||
|
|
||||||
|
c.settings_set("bal_no_willexecutor", "true")
|
||||||
|
assert c.settings_get("bal_no_willexecutor")["value"] is True
|
||||||
|
|
||||||
|
c.settings_reset("bal_tx_fees")
|
||||||
|
assert c.settings_get("bal_tx_fees")["value"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_unknown_key():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
try:
|
||||||
|
c.settings_get("bal_does_not_exist")
|
||||||
|
raise AssertionError("expected UserFacingException")
|
||||||
|
except UserFacingException as e:
|
||||||
|
assert "Unknown BAL setting" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heirs_crud():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||||
|
assert c.heirs["alice"][0] == VALID_ADDRESS
|
||||||
|
assert c.heirs["alice"][1] == "100000"
|
||||||
|
|
||||||
|
c.heirs_update("alice", amount="200000")
|
||||||
|
assert c.heirs["alice"][1] == "200000"
|
||||||
|
assert c.heirs_show("alice")["value"][1] == "200000"
|
||||||
|
|
||||||
|
assert "alice" in c.heirs_list()
|
||||||
|
c.heirs_delete(["alice"])
|
||||||
|
assert "alice" not in c.heirs_list()
|
||||||
|
|
||||||
|
|
||||||
|
def test_heirs_add_op_return():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("note", "OP_RETURN:6a0242414c", "100000")
|
||||||
|
assert c.heirs["note"][1] == "0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_willexecutors_crud():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.willexecutors == {}
|
||||||
|
|
||||||
|
new_url = "https://executor.example.invalid"
|
||||||
|
c.willexecutors_add(new_url, address="", base_fee=250)
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 250
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is False
|
||||||
|
|
||||||
|
c.willexecutors_update(new_url, base_fee="300", info="Example executor")
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["base_fee"] == 300
|
||||||
|
|
||||||
|
c.willexecutors_select([new_url], select=True)
|
||||||
|
assert c.willexecutors_show(new_url)["willexecutor"]["selected"] is True
|
||||||
|
|
||||||
|
renamed = "https://executor2.example.invalid"
|
||||||
|
c.willexecutors_update(new_url, rename_to=renamed)
|
||||||
|
assert renamed in c.willexecutors
|
||||||
|
assert new_url not in c.willexecutors
|
||||||
|
|
||||||
|
assert c.willexecutors_delete([renamed]) == {"deleted": [renamed]}
|
||||||
|
assert renamed not in c.willexecutors
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_status_empty():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
status = c.will_status()
|
||||||
|
assert status["count"] == 0
|
||||||
|
assert status["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_will_check_no_heirs_raises():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
try:
|
||||||
|
c.will_check()
|
||||||
|
raise AssertionError("expected UserFacingException")
|
||||||
|
except UserFacingException as e:
|
||||||
|
assert "heir" in str(e).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_no_heirs():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
assert c.auto_rebuild() == {"result": "no_heirs"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rebuild_threshold_passed_invalidates():
|
||||||
|
with Plugin() as plugin:
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.heirs_add("alice", VALID_ADDRESS, "100000")
|
||||||
|
plugin.USER_TYPE.set("advanced")
|
||||||
|
c.will_settings["threshold"] = int(time.time()) - 3600
|
||||||
|
result = c.auto_rebuild()
|
||||||
|
assert result["result"] == "invalidated"
|
||||||
|
assert result["reason"] == "threshold_passed"
|
||||||
|
assert result["invalidation_tx"] == {"txid": None, "tx": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_will_reanchors_date_to_check_to_new_locktime():
|
||||||
|
"""CLI mirror of the GUI regression: ``build_will`` must re-anchor
|
||||||
|
``date_to_check`` to the CURRENT heirs' earliest delivery before building,
|
||||||
|
so an anticipated (shortened) rebuild is not blocked by the old built-will
|
||||||
|
anchor (which would yield NO_FUTURE_DATE in ``get_transactions``).
|
||||||
|
"""
|
||||||
|
with Plugin() as plugin:
|
||||||
|
plugin.USER_TYPE.set("advanced")
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
plugin.ENABLE_MULTIVERSE.set(True)
|
||||||
|
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
|
||||||
|
c = _make_controller(plugin)
|
||||||
|
c.no_willexecutor = True
|
||||||
|
c.heirs["alice"] = [VALID_ADDRESS, "100%", "1y"]
|
||||||
|
|
||||||
|
# Simulate an old built will frozen at 2y: reload keeps its (stale)
|
||||||
|
# anchor, which would reject the anticipated "1y" delivery.
|
||||||
|
c.init_class_variables()
|
||||||
|
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
|
||||||
|
c.date_to_check = stale_anchor
|
||||||
|
assert Util.parse_locktime_string("1y") < c.date_to_check
|
||||||
|
|
||||||
|
with mock.patch.object(Heirs, "get_transactions", return_value={}) as gt:
|
||||||
|
result = c.build_will()
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
# build_will re-anchored date_to_check to the new 1y delivery...
|
||||||
|
expected = Util.parse_locktime_string("1y") - 150 * 86400
|
||||||
|
assert abs(c.date_to_check - expected) < 3600
|
||||||
|
# ...and used THAT anchor as the build filter, not the stale 2y one.
|
||||||
|
assert gt.call_args.args[-1] == c.date_to_check
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# runner
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = 0
|
||||||
|
for name, fn in sorted(globals().items()):
|
||||||
|
if not name.startswith("test_") or not callable(fn):
|
||||||
|
continue
|
||||||
|
print(f" {name}")
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
except Exception as e:
|
||||||
|
failures += 1
|
||||||
|
print(f" [FAIL] {name}: {e!r}")
|
||||||
|
if failures:
|
||||||
|
print(f"[FAIL] {failures} test(s) failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("[OK] All offline controller tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
139
tests/test_core_animated_qr.py
Normal file
139
tests/test_core_animated_qr.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``bal.core.animated_qr`` (BBQR interop).
|
||||||
|
|
||||||
|
Validates the self-contained BBQR codec against round trips, out-of-order
|
||||||
|
assembly, compression default/fallback, malformed-input rejection and the
|
||||||
|
format detection / parse_for_detection helpers.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
python3 tests/test_core_animated_qr.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from bal.core import animated_qr as aq
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# BBQR
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_all_encodings_roundtrip():
|
||||||
|
payload = ("BBQR payload " * 8).encode()
|
||||||
|
for encoding in ("Z", "2", "H"):
|
||||||
|
frames = aq.bbqr_frames(payload, 90, encoding=encoding)
|
||||||
|
assert len(frames) >= 1
|
||||||
|
order = list(range(len(frames)))
|
||||||
|
random.Random(3).shuffle(order)
|
||||||
|
session = aq.AnimatedQrSession()
|
||||||
|
for i in order:
|
||||||
|
session.add_part(frames[i])
|
||||||
|
assert session.done
|
||||||
|
assert session.resolve()[0] == payload.decode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_compression_default_and_fallback():
|
||||||
|
payload = ("repetitive data " * 40).encode() # compresses well
|
||||||
|
frames_z = aq.bbqr_frames(payload, 90, encoding="Z")
|
||||||
|
# Highly compressible: Z yields one frame and a 'Z' flag.
|
||||||
|
assert all(f[2] == "Z" for f in frames_z)
|
||||||
|
assert len(frames_z) == 1
|
||||||
|
raw = os.urandom(600) # incompressible
|
||||||
|
frames_2 = aq.bbqr_frames(raw, 90, encoding="Z")
|
||||||
|
assert all(f[2] == "2" for f in frames_2) # Z loses, '2' is used
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_hex_uppercase():
|
||||||
|
payload = b"\xde\xad\xbe\xef"
|
||||||
|
frame = aq.bbqr_frames(payload, 50, encoding="H")[0]
|
||||||
|
assert "DEADBEEF" in frame
|
||||||
|
encoding, _type, total, index, frag = aq.bbqr_parse_part(frame)
|
||||||
|
assert (encoding, total, index) == ("H", 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_runt_last_part():
|
||||||
|
payload = os.urandom(33)
|
||||||
|
frames = aq.bbqr_frames(payload, 60, encoding="2")
|
||||||
|
parts = [aq.bbqr_parse_part(f)[4] for f in frames]
|
||||||
|
joined = aq._bbqr_decode(parts, "2")
|
||||||
|
assert joined == payload
|
||||||
|
assert len(parts[-1]) < len(parts[0]) # last part is a runt
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_zlib_bomb_rejected():
|
||||||
|
compressed = aq._bbqr_encode(b"\x00" * 1000000, "Z")[1]
|
||||||
|
try:
|
||||||
|
aq._bbqr_decode(["0" * len(compressed)], "2") # not zlib data
|
||||||
|
except aq.AnimatedQrError:
|
||||||
|
pass
|
||||||
|
# direct inflate bomb guard:
|
||||||
|
inflated = aq._bbqr_encode(b"\x00" * 1000000, "Z")
|
||||||
|
assert inflated[0] == "Z" # 1MB zeros compresses
|
||||||
|
bomb = aq._bbqr_encode(b"\x00" * (aq._MAX_MESSAGE_BYTES + 100), "Z")[1]
|
||||||
|
parts = [bomb[i : i + 90] for i in range(0, len(bomb), 90)]
|
||||||
|
try:
|
||||||
|
aq._bbqr_decode(parts, "Z")
|
||||||
|
except aq.AnimatedQrError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected AnimatedQrError for an oversized decompression")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbqr_part_number_limits():
|
||||||
|
try:
|
||||||
|
aq.bbqr_frames(os.urandom(30000), 40, encoding="2")
|
||||||
|
except aq.AnimatedQrError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected AnimatedQrError for too many BBQR parts")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Detection / parse_for_detection
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_format_recognises_bbqr():
|
||||||
|
assert aq.detect_format(aq.bbqr_frames(b"x", 50)[0]) == "bbqr"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_format_rejects_garbage():
|
||||||
|
for text in ("", "hello world", "BALQR1|1|1||payload", "ur:", "ur:bytes/xyz"):
|
||||||
|
assert aq.detect_format(text) is None, text
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_for_detection_keys():
|
||||||
|
bb = aq.parse_for_detection(aq.bbqr_frames(b"x" * 50, 40)[0])
|
||||||
|
assert bb[0] == "bbqr" and bb[2] >= 1 and 0 <= bb[3] < bb[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_names_exist():
|
||||||
|
assert aq.format_name("bbqr")
|
||||||
|
assert aq.format_name("nope") == "nope"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for _name, fn in sorted(globals().items()):
|
||||||
|
if _name.startswith("test_") and callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print("ok: {}".format(_name))
|
||||||
|
except Exception:
|
||||||
|
failures += 1
|
||||||
|
print("FAIL: {}".format(_name))
|
||||||
|
traceback.print_exc()
|
||||||
|
if failures:
|
||||||
|
print("{} test(s) failed".format(failures))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all tests passed")
|
||||||
410
tests/test_core_checkalive.py
Normal file
410
tests/test_core_checkalive.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``bal.core.checkalive`` (pure, GUI-free).
|
||||||
|
|
||||||
|
Covers the CheckAlive exceptions and the BASIC/ADVANCED date_to_check policy
|
||||||
|
(``resolve_date_to_check`` / ``resolve_relative_check_alive_date`` /
|
||||||
|
``is_check_alive_date_valid`` / ``ensure_check_alive_valid``), including the
|
||||||
|
sign rule: a NEGATIVE relative Check Alive counts back from the delivery, a
|
||||||
|
POSITIVE one counts forward from the inheritance creation date.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 tests/test_core_checkalive.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||||
|
CheckAliveAfterDeliveryError,
|
||||||
|
CheckAliveError,
|
||||||
|
ensure_check_alive_valid,
|
||||||
|
is_check_alive_date_valid,
|
||||||
|
resolve_date_to_check,
|
||||||
|
resolve_delivery_date,
|
||||||
|
resolve_guard_threshold,
|
||||||
|
resolve_relative_check_alive_date,
|
||||||
|
)
|
||||||
|
from bal.core.plugin_base import BalTimestamp # noqa: E402
|
||||||
|
from bal.core.will import Will # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _midnight(ts):
|
||||||
|
return (
|
||||||
|
datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||||
|
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# CheckAliveError / CheckAliveAfterDeliveryError
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_alive_error_default():
|
||||||
|
err = CheckAliveError(1000000)
|
||||||
|
assert err.timestamp_to_check == 1000000
|
||||||
|
assert err.reason is None
|
||||||
|
|
||||||
|
|
||||||
|
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_str_with_reason():
|
||||||
|
err = CheckAliveError(1000000, reason="too early")
|
||||||
|
assert "too early" in str(err)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_alive_error_subclass():
|
||||||
|
assert issubclass(CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_alive_after_delivery_error_is_sibling():
|
||||||
|
# NOT a subclass of CheckAliveError: the existing ``except CheckAliveError``
|
||||||
|
# handlers must not invalidate the will for this configuration error.
|
||||||
|
assert not issubclass(CheckAliveAfterDeliveryError, CheckAliveError)
|
||||||
|
err = CheckAliveAfterDeliveryError(2000, 1000)
|
||||||
|
assert err.timestamp_to_check == 2000
|
||||||
|
assert err.delivery_timestamp == 1000
|
||||||
|
assert "delivery" in str(err)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# resolve_delivery_date
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_absolute_locktime():
|
||||||
|
settings = {"locktime": 1_800_000_000}
|
||||||
|
assert resolve_delivery_date(settings) == 1_800_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_relative_locktime_resolved_from_now():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"locktime": "90d"}
|
||||||
|
result = resolve_delivery_date(settings, now=fake_now)
|
||||||
|
expected = _midnight(fake_now) + 90 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_relative_locktime_anchored_to_built_tx():
|
||||||
|
frozen = 1817424000
|
||||||
|
settings = {"locktime": "2y"}
|
||||||
|
result = resolve_delivery_date(
|
||||||
|
settings, now=1_800_000_000.0, built_locktime=frozen
|
||||||
|
)
|
||||||
|
assert result == frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_missing_locktime_returns_none():
|
||||||
|
assert resolve_delivery_date({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# resolve_relative_check_alive_date (the single calculation rule)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_negative_anchors_to_delivery():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
delivery = fake_now + 90 * 86400
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"-30d", delivery_date=delivery, creation_date=fake_now, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(delivery) - 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
assert result < delivery
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_positive_anchors_to_creation():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
creation = fake_now + 5 * 86400
|
||||||
|
delivery = fake_now + 365 * 86400
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"30d", delivery_date=delivery, creation_date=creation, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(creation) + 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
# The delivery anchor is irrelevant for a positive value.
|
||||||
|
assert result < delivery
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_positive_without_creation_falls_back_to_now():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"30d", delivery_date=fake_now + 365 * 86400, creation_date=None, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(fake_now) + 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_negative_without_delivery_falls_back_to_now():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
result = resolve_relative_check_alive_date(
|
||||||
|
"-30d", delivery_date=None, creation_date=fake_now, now=fake_now
|
||||||
|
)
|
||||||
|
expected = _midnight(fake_now) - 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_absolute_value_returned_unchanged():
|
||||||
|
assert resolve_relative_check_alive_date(1_700_000_000, None, None) == 1_700_000_000
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 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_negative_relative_anchored_to_delivery():
|
||||||
|
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)
|
||||||
|
expected = _midnight(locktime) - 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
assert result > fake_now
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_positive_relative_anchored_to_creation():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
creation = fake_now + 10 * 86400
|
||||||
|
locktime = fake_now + 365 * 86400
|
||||||
|
settings = {"threshold": "30d", "locktime": locktime}
|
||||||
|
result = resolve_date_to_check(
|
||||||
|
False, settings, now=fake_now, creation_date=creation
|
||||||
|
)
|
||||||
|
expected = _midnight(creation) + 30 * 86400
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_negative_relative_never_drifts():
|
||||||
|
"""A negative relative threshold anchored to a fixed absolute delivery does
|
||||||
|
not drift with the clock."""
|
||||||
|
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)
|
||||||
|
second = resolve_date_to_check(False, settings, now=fake_now + 86400)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_positive_relative_creation_never_drifts():
|
||||||
|
"""A positive relative threshold anchored to the frozen creation date does
|
||||||
|
not drift with the clock."""
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
creation = fake_now + 2 * 86400
|
||||||
|
settings = {"threshold": "30d", "locktime": fake_now + 365 * 86400}
|
||||||
|
first = resolve_date_to_check(
|
||||||
|
False, settings, now=fake_now, creation_date=creation
|
||||||
|
)
|
||||||
|
second = resolve_date_to_check(
|
||||||
|
False, settings, now=fake_now + 5 * 86400, creation_date=creation
|
||||||
|
)
|
||||||
|
assert first == second
|
||||||
|
|
||||||
|
|
||||||
|
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 = 1817424000 # frozen tx locktime, built 2026-08-05
|
||||||
|
settings = {"threshold": "-30d", "locktime": "2y"}
|
||||||
|
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
|
||||||
|
assert first < frozen
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_relative_locktime_without_built_tx_falls_back():
|
||||||
|
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(fake_now)
|
||||||
|
expected = BalTimestamp("-30d").to_date(locktime_dt).timestamp()
|
||||||
|
assert abs(result - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# resolve_guard_threshold
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_basic_mode_returns_none():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
threshold = resolve_guard_threshold(True, {"threshold": "-30d"}, now=fake_now)
|
||||||
|
assert threshold is None
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_locktime(settings, fake_now):
|
||||||
|
"""Reproduce the call-site locktime expression of the guard."""
|
||||||
|
return BalTimestamp(settings["locktime"]).to_timestamp(fake_now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_absolute():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
locktime = fake_now + 90 * 86400
|
||||||
|
threshold = locktime - 30 * 86400
|
||||||
|
settings = {"locktime": locktime, "threshold": threshold}
|
||||||
|
assert resolve_guard_threshold(False, settings, now=fake_now) == threshold
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_negative_relative_fresh_anchor():
|
||||||
|
"""A negative relative threshold is anchored to the FRESH locktime so the
|
||||||
|
guard and the settings always share one reference frame."""
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
settings = {"locktime": "1y", "threshold": "-30d"}
|
||||||
|
locktime = _guard_locktime(settings, fake_now)
|
||||||
|
threshold = resolve_guard_threshold(False, settings, now=fake_now)
|
||||||
|
assert threshold is not None
|
||||||
|
assert locktime > threshold # internally consistent: no fire
|
||||||
|
assert threshold > fake_now
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_positive_relative_anchored_to_creation():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
creation = fake_now + 4 * 86400
|
||||||
|
settings = {"locktime": "1y", "threshold": "30d"}
|
||||||
|
threshold = resolve_guard_threshold(
|
||||||
|
False, settings, now=fake_now, creation_date=creation
|
||||||
|
)
|
||||||
|
expected = _midnight(creation) + 30 * 86400
|
||||||
|
assert abs(threshold - expected) < 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_threshold_missing_returns_none():
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
assert resolve_guard_threshold(False, {"locktime": "1y"}, now=fake_now) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# is_check_alive_date_valid / ensure_check_alive_valid
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_inside_window():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
assert is_check_alive_date_valid(now + 100, now + 1000, now=now) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_rejects_past():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
assert is_check_alive_date_valid(now - 100, now + 1000, now=now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_rejects_on_or_after_delivery():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
delivery = now + 1000
|
||||||
|
assert is_check_alive_date_valid(delivery, delivery, now=now) is False
|
||||||
|
assert is_check_alive_date_valid(delivery + 1, delivery, now=now) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_without_delivery_skips_upper_bound():
|
||||||
|
now = 1_800_000_000.0
|
||||||
|
assert is_check_alive_date_valid(now + 100, None, now=now) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_valid_none_is_false():
|
||||||
|
assert is_check_alive_date_valid(None, 1000) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_mode_never_expired():
|
||||||
|
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
|
||||||
|
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_raises_when_past():
|
||||||
|
try:
|
||||||
|
ensure_check_alive_valid(False, time.time() - 10_000)
|
||||||
|
except CheckAliveError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected CheckAliveError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_raises_when_after_delivery():
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
ensure_check_alive_valid(False, now + 100, delivery_date=now + 50)
|
||||||
|
except CheckAliveAfterDeliveryError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected CheckAliveAfterDeliveryError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_valid_does_not_raise():
|
||||||
|
now = time.time()
|
||||||
|
assert ensure_check_alive_valid(False, now + 100, delivery_date=now + 1000) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.get_creation_time
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWillItem:
|
||||||
|
def __init__(self, time_value, valid=True):
|
||||||
|
self.time = time_value
|
||||||
|
self._valid = valid
|
||||||
|
|
||||||
|
def get_status(self, status):
|
||||||
|
return self._valid if status == "VALID" else False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_creation_time_min_of_valid_items():
|
||||||
|
will = {
|
||||||
|
"a": _FakeWillItem(2000),
|
||||||
|
"b": _FakeWillItem(1000),
|
||||||
|
"c": _FakeWillItem(3000),
|
||||||
|
}
|
||||||
|
assert Will.get_creation_time(will) == 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_creation_time_ignores_invalid_and_none():
|
||||||
|
will = {
|
||||||
|
"a": _FakeWillItem(500, valid=False),
|
||||||
|
"b": _FakeWillItem(None),
|
||||||
|
"c": _FakeWillItem(2000),
|
||||||
|
}
|
||||||
|
assert Will.get_creation_time(will) == 2000
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_creation_time_empty_returns_none():
|
||||||
|
assert Will.get_creation_time({}) 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 checkalive tests passed")
|
||||||
@@ -9,22 +9,34 @@ Run:
|
|||||||
python3 tests/test_core_heirs.py
|
python3 tests/test_core_heirs.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
HEIR_ADDRESS,
|
||||||
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
HEIR_AMOUNT,
|
||||||
create_op_return_script,
|
HEIR_DUST_AMOUNT,
|
||||||
|
HEIR_LOCKTIME,
|
||||||
|
HEIR_REAL_AMOUNT,
|
||||||
|
OP_RETURN_PREFIX,
|
||||||
|
TRANSACTION_LABEL,
|
||||||
AliasNotFoundException,
|
AliasNotFoundException,
|
||||||
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
AmountNotValid,
|
||||||
HeirExpiredException, HeirAmountIsDustException,
|
|
||||||
NoHeirsException, WillExecutorFeeException,
|
|
||||||
BalanceTooLowException,
|
BalanceTooLowException,
|
||||||
|
HeirAmountIsDustException,
|
||||||
Heirs,
|
Heirs,
|
||||||
|
LocktimeNotValid,
|
||||||
|
NoHeirsException,
|
||||||
|
NotAnAddress,
|
||||||
|
WillExecutorFeeException,
|
||||||
|
create_op_return_script,
|
||||||
|
get_op_return_hex,
|
||||||
|
is_op_return_address,
|
||||||
|
validate_op_return_hex,
|
||||||
)
|
)
|
||||||
|
from bal.core.util import Util
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Constants
|
# Constants
|
||||||
@@ -68,7 +80,7 @@ def test_op_return_empty():
|
|||||||
def test_op_return_too_big():
|
def test_op_return_too_big():
|
||||||
try:
|
try:
|
||||||
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
||||||
assert False, "expected ValueError"
|
raise AssertionError("expected ValueError")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -156,6 +168,32 @@ def test_heirs_amount_to_float():
|
|||||||
assert heirs.amount_to_float("notanumber") == 0.0
|
assert heirs.amount_to_float("notanumber") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixed_percent_lists_uses_build_anchor_for_relative_heirs():
|
||||||
|
"""A relative heir must survive the amount filter when the build anchor
|
||||||
|
(``from_locktime``) is recalculated for the anticipated delivery.
|
||||||
|
|
||||||
|
Before the fix, ``build_will`` kept ``date_to_check`` anchored to the OLD
|
||||||
|
(longer) built will; an "1y" heir resolved before that anchor was excluded
|
||||||
|
by the ``cmp <= 0`` filter and the build reported NO_FUTURE_DATE. With the
|
||||||
|
anchor recomputed for the new locktime (karen7: 2y -> 1y delivery) the
|
||||||
|
"1y" heir is kept.
|
||||||
|
"""
|
||||||
|
wallet = FakeWallet()
|
||||||
|
heirs = Heirs(wallet)
|
||||||
|
heirs["carol"] = ["addr1", "100%", "1y"]
|
||||||
|
|
||||||
|
# Stale anchor (old built 2y will still frozen): "1y" is in the past
|
||||||
|
# relative to it -> excluded from the amount calculation.
|
||||||
|
stale_anchor = Util.parse_locktime_string("2y") - 150 * 86400
|
||||||
|
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(stale_anchor, 500)
|
||||||
|
assert "carol" not in percent_heirs
|
||||||
|
|
||||||
|
# Recalculated anchor for the new (1y) delivery: the heir is retained.
|
||||||
|
new_anchor = Util.parse_locktime_string("1y") - 150 * 86400
|
||||||
|
_, _, percent_heirs, _, _ = heirs.fixed_percent_lists_amount(new_anchor, 500)
|
||||||
|
assert "carol" in percent_heirs
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Validation (static methods)
|
# Validation (static methods)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -177,13 +215,13 @@ def test_validate_amount():
|
|||||||
# Invalid
|
# Invalid
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("0.000000001")
|
Heirs.validate_amount("0.000000001")
|
||||||
assert False, "expected AmountNotValid"
|
raise AssertionError("expected AmountNotValid")
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Heirs.validate_amount("-1")
|
Heirs.validate_amount("-1")
|
||||||
assert False, "expected AmountNotValid"
|
raise AssertionError("expected AmountNotValid")
|
||||||
except AmountNotValid:
|
except AmountNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -207,7 +245,7 @@ def test_validate_locktime_expired():
|
|||||||
past = int(time.time()) - 86400 # yesterday
|
past = int(time.time()) - 86400 # yesterday
|
||||||
try:
|
try:
|
||||||
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
||||||
assert False, "expected LocktimeNotValid"
|
raise AssertionError("expected LocktimeNotValid")
|
||||||
except LocktimeNotValid:
|
except LocktimeNotValid:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -258,9 +296,99 @@ def test_validate_removes_invalid():
|
|||||||
assert "alice" in result or True # may or may not pass address check
|
assert "alice" in result or True # may or may not pass address check
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# OP_RETURN helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_op_return_prefix_constant():
|
||||||
|
assert OP_RETURN_PREFIX == "OP_RETURN:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_op_return_address():
|
||||||
|
assert is_op_return_address("OP_RETURN:48656c6c6f")
|
||||||
|
assert not is_op_return_address("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
|
||||||
|
assert not is_op_return_address("")
|
||||||
|
assert not is_op_return_address("OP_RETURN")
|
||||||
|
assert not is_op_return_address("OP_RETURNX:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_op_return_hex():
|
||||||
|
assert get_op_return_hex("OP_RETURN:48656c6c6f") == "48656c6c6f"
|
||||||
|
assert get_op_return_hex("bc1q...") is None
|
||||||
|
assert get_op_return_hex("") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_valid():
|
||||||
|
validate_op_return_hex("48656c6c6f")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_invalid():
|
||||||
|
try:
|
||||||
|
validate_op_return_hex("nothex!!")
|
||||||
|
raise AssertionError("expected NotAnAddress")
|
||||||
|
except NotAnAddress:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_too_long():
|
||||||
|
try:
|
||||||
|
validate_op_return_hex("ab" * 81)
|
||||||
|
raise AssertionError("expected NotAnAddress")
|
||||||
|
except NotAnAddress:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_op_return_hex_empty():
|
||||||
|
validate_op_return_hex("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_address_op_return():
|
||||||
|
addr = "OP_RETURN:48656c6c6f"
|
||||||
|
result = Heirs.validate_address(addr)
|
||||||
|
assert result == addr
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_heir_op_return():
|
||||||
|
k = "test_op_return"
|
||||||
|
v = ["OP_RETURN:48656c6c6f", "0", "30d"]
|
||||||
|
result = Heirs.validate_heir(k, v)
|
||||||
|
assert result[0] == "OP_RETURN:48656c6c6f"
|
||||||
|
assert result[1] == "0"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Heirs class OP_RETURN integration
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_heirs_fixed_percent_skips_op_return():
|
||||||
|
class FakeWallet:
|
||||||
|
class FakeDB:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
def put(self, key, value):
|
||||||
|
self._data[key] = value
|
||||||
|
def __init__(self):
|
||||||
|
self.db = self.FakeDB()
|
||||||
|
self.dust_threshold = lambda: 500
|
||||||
|
wallet = FakeWallet()
|
||||||
|
heirs = Heirs(wallet)
|
||||||
|
heirs["op_ret"] = ["OP_RETURN:48656c6c6f", "0", "9999999999"]
|
||||||
|
heirs["normal"] = ["addr1", "10000", "9999999999"]
|
||||||
|
fixed_h, fixed_amt, perc_h, perc_amt, fixed_with_dust = (
|
||||||
|
heirs.fixed_percent_lists_amount(0, 500)
|
||||||
|
)
|
||||||
|
assert "op_ret" in fixed_h
|
||||||
|
assert "normal" in fixed_h
|
||||||
|
assert fixed_h["op_ret"][HEIR_REAL_AMOUNT] == 0
|
||||||
|
assert fixed_h["normal"][HEIR_REAL_AMOUNT] == 10000
|
||||||
|
assert fixed_amt == 10000 # OP_RETURN adds 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for name in sorted(dir()):
|
for name in sorted(dir()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
print(f" [OK] {name}")
|
print(f" [OK] {name}")
|
||||||
print(f"[OK] All heirs tests passed")
|
print("[OK] All heirs tests passed")
|
||||||
|
|||||||
@@ -8,19 +8,20 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import (
|
from bal.core.heirs import (
|
||||||
Heirs, create_op_return_script, reduce_outputs,
|
HEIR_AMOUNT,
|
||||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
Heirs,
|
||||||
|
create_op_return_script,
|
||||||
|
reduce_outputs,
|
||||||
)
|
)
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Heirs db-dependent methods
|
# Heirs db-dependent methods
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -139,6 +140,7 @@ def test_prepare_lists_mixed_dust_continues():
|
|||||||
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
|
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
|
||||||
})
|
})
|
||||||
raised = False
|
raised = False
|
||||||
|
result = None
|
||||||
try:
|
try:
|
||||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||||
except HeirAmountIsDustException:
|
except HeirAmountIsDustException:
|
||||||
@@ -165,6 +167,7 @@ def test_prepare_lists_multi_locktime_continues():
|
|||||||
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
|
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
|
||||||
})
|
})
|
||||||
raised = False
|
raised = False
|
||||||
|
result = None
|
||||||
try:
|
try:
|
||||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||||
except HeirAmountIsDustException:
|
except HeirAmountIsDustException:
|
||||||
@@ -188,7 +191,7 @@ def test_validate_address_invalid():
|
|||||||
from bal.core.heirs import NotAnAddress
|
from bal.core.heirs import NotAnAddress
|
||||||
try:
|
try:
|
||||||
Heirs.validate_address("bad")
|
Heirs.validate_address("bad")
|
||||||
assert False, "should have raised"
|
raise AssertionError("should have raised")
|
||||||
except NotAnAddress:
|
except NotAnAddress:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
189
tests/test_core_input_rules.py
Normal file
189
tests/test_core_input_rules.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
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 "$BAL_HOME/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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locktime_raw_negative_disallowed_by_default():
|
||||||
|
# The delivery field must never accept a sign.
|
||||||
|
s, isdays, isyears, pos = normalize_locktime_raw_text("-30d", 4)
|
||||||
|
assert s == "30d"
|
||||||
|
assert isdays is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locktime_raw_negative_allowed_keeps_single_sign():
|
||||||
|
s, isdays, isyears, pos = normalize_locktime_raw_text(
|
||||||
|
"-30d", 4, allow_negative=True
|
||||||
|
)
|
||||||
|
assert s == "-30d"
|
||||||
|
assert isdays is True
|
||||||
|
assert pos >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_locktime_raw_negative_only_leading():
|
||||||
|
# Extra signs are dropped; a single leading sign survives.
|
||||||
|
s, _isdays, _isyears, _pos = normalize_locktime_raw_text(
|
||||||
|
"--1y", 4, allow_negative=True
|
||||||
|
)
|
||||||
|
assert s == "-1y"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 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,14 +8,15 @@ Run:
|
|||||||
python3 tests/test_core_plugin_base.py
|
python3 tests/test_core_plugin_base.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
|
|
||||||
|
|
||||||
|
from bal.core.plugin_base import BalConfig, BalPlugin, BalTimestamp
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# BalTimestamp
|
# BalTimestamp
|
||||||
@@ -73,7 +74,7 @@ def test_bt_to_date_absolute():
|
|||||||
|
|
||||||
|
|
||||||
def test_bt_to_date_relative():
|
def test_bt_to_date_relative():
|
||||||
now = datetime.now()
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# relative days from now
|
# relative days from now
|
||||||
bt = BalTimestamp("7d")
|
bt = BalTimestamp("7d")
|
||||||
@@ -85,8 +86,8 @@ def test_bt_to_date_relative():
|
|||||||
d_rev = bt.to_date(reverse=True)
|
d_rev = bt.to_date(reverse=True)
|
||||||
assert d_rev < now
|
assert d_rev < now
|
||||||
|
|
||||||
# from explicit datetime
|
# from explicit datetime (UTC, so the naive-timestamp roundtrip below is stable)
|
||||||
base = datetime(2025, 6, 1, 12, 0, 0)
|
base = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
d = bt.to_date(from_date=base)
|
d = bt.to_date(from_date=base)
|
||||||
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
assert d == expected
|
assert d == expected
|
||||||
@@ -100,7 +101,7 @@ def test_bt_to_date_relative():
|
|||||||
def test_bt_to_date_years():
|
def test_bt_to_date_years():
|
||||||
bt = BalTimestamp("1y")
|
bt = BalTimestamp("1y")
|
||||||
d = bt.to_date()
|
d = bt.to_date()
|
||||||
assert d > datetime.now()
|
assert d > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def test_bt_to_date_overflow():
|
def test_bt_to_date_overflow():
|
||||||
@@ -200,13 +201,13 @@ def test_balconfig_set():
|
|||||||
|
|
||||||
def test_default_will_settings_relative():
|
def test_default_will_settings_relative():
|
||||||
rel = BalPlugin.default_will_settings_relative()
|
rel = BalPlugin.default_will_settings_relative()
|
||||||
assert rel["threshold"] == "30d"
|
assert rel["threshold"] == "-30d"
|
||||||
assert rel["locktime"] == "1y"
|
assert rel["locktime"] == "1y"
|
||||||
|
|
||||||
|
|
||||||
def test_default_will_settings():
|
def test_default_will_settings():
|
||||||
settings = BalPlugin.default_will_settings()
|
settings = BalPlugin.default_will_settings()
|
||||||
assert settings["baltx_fees"] == 100
|
assert settings["baltx_fees"] == 20
|
||||||
assert "threshold" in settings
|
assert "threshold" in settings
|
||||||
assert "locktime" in settings
|
assert "locktime" in settings
|
||||||
# threshold/locktime should be absolute timestamps
|
# threshold/locktime should be absolute timestamps
|
||||||
@@ -228,7 +229,7 @@ def test_validate_will_settings():
|
|||||||
# Note: passing None triggers `will_settings = []` which then fails
|
# Note: passing None triggers `will_settings = []` which then fails
|
||||||
# on .get(). This is a latent bug — test passing a dict directly
|
# on .get(). This is a latent bug — test passing a dict directly
|
||||||
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
|
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
|
||||||
assert result["baltx_fees"] == 100
|
assert result["baltx_fees"] == 20
|
||||||
|
|
||||||
# normal settings unchanged
|
# normal settings unchanged
|
||||||
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}
|
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}
|
||||||
|
|||||||
81
tests/test_core_qr_transfer.py
Normal file
81
tests/test_core_qr_transfer.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``bal.core.qrtransfer``.
|
||||||
|
|
||||||
|
Covers the transfer-string encoding used for will transfer via BBQR QR codes
|
||||||
|
/ audio modem: encoding, decoding and the preset list.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
source electrum/env/bin/activate
|
||||||
|
python3 tests/test_core_qr_transfer.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from bal.core.qrtransfer import (
|
||||||
|
CHUNK_PRESETS,
|
||||||
|
decode_transfer,
|
||||||
|
encode_transfer,
|
||||||
|
preset_index_for_chunk_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Round trips
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_plain():
|
||||||
|
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||||
|
payload = encode_transfer(tx_strings, compress=False)
|
||||||
|
assert decode_transfer(payload, compressed=False) == tx_strings
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_compressed():
|
||||||
|
tx_strings = ["00" * 32, "aa" * 40, "ff" * 50]
|
||||||
|
payload = encode_transfer(tx_strings, compress=True)
|
||||||
|
assert decode_transfer(payload, compressed=True) == tx_strings
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_roundtrip():
|
||||||
|
assert decode_transfer(encode_transfer([]), compressed=False) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Constants / presets
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_count_and_order():
|
||||||
|
assert len(CHUNK_PRESETS) == 4
|
||||||
|
budgets = [budget for _label, budget in CHUNK_PRESETS]
|
||||||
|
assert budgets == sorted(budgets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preset_index_for_chunk_size():
|
||||||
|
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||||
|
assert preset_index_for_chunk_size(budget) == index
|
||||||
|
assert preset_index_for_chunk_size(150) == 0
|
||||||
|
assert preset_index_for_chunk_size(1800) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for _name, fn in sorted(globals().items()):
|
||||||
|
if _name.startswith("test_") and callable(fn):
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print("ok: {}".format(_name))
|
||||||
|
except Exception:
|
||||||
|
failures += 1
|
||||||
|
print("FAIL: {}".format(_name))
|
||||||
|
traceback.print_exc()
|
||||||
|
if failures:
|
||||||
|
print("{} test(s) failed".format(failures))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all tests passed")
|
||||||
299
tests/test_core_reminders.py
Normal file
299
tests/test_core_reminders.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"""
|
||||||
|
Tests for ``bal.core.reminders`` (pure, GUI-free).
|
||||||
|
|
||||||
|
Covers the reminder-offset rules (BASIC + ADVANCED), the RFC-5545 helpers
|
||||||
|
(format_time, ical_escape, fold_ical_line), write_temp_ics, and the unified
|
||||||
|
``build_ics_reminders`` builder.
|
||||||
|
|
||||||
|
This module imports only ``bal.core``, so it runs in the lint venv (no
|
||||||
|
Electrum/Qt needed) as well as the runtime venv:
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python3 tests/test_core_reminders.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from bal.core.reminders import ( # noqa: E402 (path insert above)
|
||||||
|
BASIC_REMINDER_OFFSETS,
|
||||||
|
basic_reminder_offsets,
|
||||||
|
build_ics_reminders,
|
||||||
|
compute_reminder_offsets,
|
||||||
|
fold_ical_line,
|
||||||
|
format_time,
|
||||||
|
ical_escape,
|
||||||
|
write_temp_ics,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# compute_reminder_offsets - distribution rules
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_offsets_long_period_three_reminders():
|
||||||
|
"""30-day period, 3 reminders: spread out, all before the deadline."""
|
||||||
|
offsets = compute_reminder_offsets(30, 3)
|
||||||
|
assert len(offsets) == 3
|
||||||
|
# All strictly before the deadline (offset >= 1 means "n days before end").
|
||||||
|
assert all(o >= 1 for o in offsets)
|
||||||
|
# Sorted earliest-first (largest offset first).
|
||||||
|
assert offsets == sorted(offsets, reverse=True)
|
||||||
|
# One reminder near the start, one near the end.
|
||||||
|
assert max(offsets) == 30
|
||||||
|
assert min(offsets) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_offsets_capped_at_one_per_day():
|
||||||
|
"""Short period: at most one reminder per available day."""
|
||||||
|
# 2 days but 3 requested -> only 2 reminders, one per day.
|
||||||
|
assert compute_reminder_offsets(2, 3) == [2, 1]
|
||||||
|
# 1 day but 3 requested -> a single reminder, the day before the deadline.
|
||||||
|
assert compute_reminder_offsets(1, 3) == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_offsets_empty_when_no_room():
|
||||||
|
"""No reminders when there is no day before the deadline."""
|
||||||
|
assert compute_reminder_offsets(0, 3) == []
|
||||||
|
assert compute_reminder_offsets(-5, 3) == []
|
||||||
|
# A non-positive count also yields nothing.
|
||||||
|
assert compute_reminder_offsets(30, 0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_offsets_never_exceed_requested_count():
|
||||||
|
"""The number of reminders never exceeds the requested count (max 5)."""
|
||||||
|
offsets = compute_reminder_offsets(100, 5)
|
||||||
|
assert len(offsets) == 5
|
||||||
|
assert all(o >= 1 for o in offsets)
|
||||||
|
# Distinct offsets only (no duplicate alarms on the same day).
|
||||||
|
assert len(set(offsets)) == len(offsets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offsets_single_reminder_is_day_before_deadline():
|
||||||
|
"""A single requested reminder fires one day before the deadline."""
|
||||||
|
assert compute_reminder_offsets(30, 1) == [1]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# basic_reminder_offsets - fixed BASIC-mode offsets
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_offsets_full_period():
|
||||||
|
"""A far-away delivery keeps all three fixed offsets (30, 10, 1)."""
|
||||||
|
assert basic_reminder_offsets(365) == [30, 10, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_offsets_truncated_by_horizon():
|
||||||
|
"""Offsets beyond the delivery horizon are dropped."""
|
||||||
|
assert basic_reminder_offsets(20) == [10, 1]
|
||||||
|
assert basic_reminder_offsets(5) == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_offsets_empty():
|
||||||
|
"""No future offsets when the delivery is less than a day away."""
|
||||||
|
assert basic_reminder_offsets(0) == []
|
||||||
|
assert basic_reminder_offsets(-10) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_offsets_always_from_fixed_set():
|
||||||
|
"""Every returned offset is one of the fixed BASIC offsets."""
|
||||||
|
for horizon in range(0, 40):
|
||||||
|
result = basic_reminder_offsets(horizon)
|
||||||
|
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# format_time
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_utc():
|
||||||
|
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
|
||||||
|
assert format_time(dt) == "20250601T123045Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_time_non_utc():
|
||||||
|
tz = timezone(timedelta(hours=2))
|
||||||
|
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=tz)
|
||||||
|
assert format_time(dt) == "20250601T103045Z"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# ical_escape
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_no_change():
|
||||||
|
assert ical_escape("hello world") == "hello world"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_backslash():
|
||||||
|
assert ical_escape("a\\b") == "a\\\\b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_semicolon():
|
||||||
|
assert ical_escape("a;b") == "a\\;b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_comma():
|
||||||
|
assert ical_escape("a,b") == "a\\,b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_multiline():
|
||||||
|
text = "line1\nline2"
|
||||||
|
result = ical_escape(text)
|
||||||
|
assert "line1" in result
|
||||||
|
assert "line2" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_ical_escape_all():
|
||||||
|
assert ical_escape("\\;,") == "\\\\\\;\\,"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# fold_ical_line
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_ical_line_short():
|
||||||
|
assert fold_ical_line("SUMMARY:Test") == "SUMMARY:Test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_ical_line_long():
|
||||||
|
line = "SUMMARY:" + "a" * 100
|
||||||
|
result = fold_ical_line(line, limit=75)
|
||||||
|
parts = result.split("\r\n ")
|
||||||
|
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
|
||||||
|
assert "".join(parts) == line
|
||||||
|
|
||||||
|
|
||||||
|
def test_fold_ical_line_unicode():
|
||||||
|
line = "SUMMARY:" + "é" * 50
|
||||||
|
result = fold_ical_line(line, limit=75)
|
||||||
|
parts = result.split("\r\n ")
|
||||||
|
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
|
||||||
|
assert "".join(parts) == line
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# write_temp_ics
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_temp_ics():
|
||||||
|
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
||||||
|
path = write_temp_ics(content)
|
||||||
|
try:
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
assert f.read() == content.encode("utf-8")
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_temp_ics_empty():
|
||||||
|
path = write_temp_ics("")
|
||||||
|
try:
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
assert f.read() == b""
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# build_ics_reminders - unified builder
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
_LOCKTIME = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _common_kwargs():
|
||||||
|
return dict(
|
||||||
|
locktime=_LOCKTIME,
|
||||||
|
description="BAL will of $wallet_name\r\n$heirs_complete",
|
||||||
|
summary="BAL - Will execution of $wallet_name",
|
||||||
|
wallet_name="karen7",
|
||||||
|
heirs_details=" alice - bc1qalice, bob - bc1qbob",
|
||||||
|
version="0.6.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ics_advanced_separate_events():
|
||||||
|
"""ADVANCED: 30-day period -> offsets [30, 16, 1], one VEVENT each."""
|
||||||
|
content = build_ics_reminders(
|
||||||
|
basic_mode=False,
|
||||||
|
num_reminders=3,
|
||||||
|
now=datetime(2026, 7, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
threshold=datetime(2026, 6, 23, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
**_common_kwargs(),
|
||||||
|
)
|
||||||
|
assert content is not None
|
||||||
|
assert content.startswith("BEGIN:VCALENDAR")
|
||||||
|
assert content.endswith("\r\n")
|
||||||
|
lines = content.split("\r\n")
|
||||||
|
assert lines.count("BEGIN:VEVENT") == 3
|
||||||
|
assert lines.count("END:VEVENT") == 3
|
||||||
|
assert "BEGIN:VALARM" not in lines
|
||||||
|
# Unique UIDs, numbered summaries, last event one day before the deadline.
|
||||||
|
uids = [ln for ln in lines if ln.startswith("UID:")]
|
||||||
|
assert len(uids) == len(set(uids)) == 3
|
||||||
|
assert any("(reminder 1/3)" in ln for ln in lines)
|
||||||
|
assert any("(reminder 3/3)" in ln for ln in lines)
|
||||||
|
last_dt = format_time(_LOCKTIME - timedelta(days=1))
|
||||||
|
assert f"DTSTART:{last_dt}" in lines
|
||||||
|
# Template substitution; the CRLF inside the description stays as a line
|
||||||
|
# break in the folded DESCRIPTION (no literal "\n" escaping in ical_escape).
|
||||||
|
desc = [ln for ln in lines if ln.startswith("DESCRIPTION:")]
|
||||||
|
assert desc and "karen7" in desc[0]
|
||||||
|
assert any(ln.lstrip().startswith("alice") for ln in lines)
|
||||||
|
|
||||||
|
def test_build_ics_basic_uses_fixed_offsets():
|
||||||
|
"""BASIC: fixed offsets (30, 10, 1) filtered to the future, threshold not
|
||||||
|
required."""
|
||||||
|
content = build_ics_reminders(
|
||||||
|
basic_mode=True,
|
||||||
|
now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
**_common_kwargs(),
|
||||||
|
)
|
||||||
|
assert content is not None
|
||||||
|
lines = content.split("\r\n")
|
||||||
|
uids = [ln for ln in lines if ln.startswith("UID:")]
|
||||||
|
assert len(uids) == 3
|
||||||
|
# The first event is the 30-day offset.
|
||||||
|
assert "bal-karen7-30d" in uids[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ics_returns_none_when_no_reminders():
|
||||||
|
"""No future reminder -> None (the aligned behavior), not an empty file."""
|
||||||
|
content = build_ics_reminders(
|
||||||
|
basic_mode=True,
|
||||||
|
now=datetime(2026, 7, 23, 0, 0, 0, tzinfo=timezone.utc),
|
||||||
|
**_common_kwargs(),
|
||||||
|
)
|
||||||
|
assert content is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ics_advanced_requires_threshold():
|
||||||
|
"""ADVANCED mode without a threshold is a programming error."""
|
||||||
|
try:
|
||||||
|
build_ics_reminders(basic_mode=False, **_common_kwargs())
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
raise AssertionError("expected ValueError for missing threshold in ADVANCED")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All core reminders tests passed")
|
||||||
@@ -9,13 +9,14 @@ Run:
|
|||||||
python3 tests/test_core_util.py
|
python3 tests/test_core_util.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
import pytest
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
from bal.core.util import Util
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_to_str():
|
def test_locktime_to_str():
|
||||||
@@ -40,7 +41,7 @@ def test_str_to_locktime():
|
|||||||
|
|
||||||
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
|
# the block-height suffix "b" was removed (A1): "144b" is no longer a valid
|
||||||
# relative locktime, so it is NOT passed through unchanged.
|
# relative locktime, so it is NOT passed through unchanged.
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(ValueError):
|
||||||
Util.str_to_locktime("144b")
|
Util.str_to_locktime("144b")
|
||||||
|
|
||||||
# integer string -> int
|
# integer string -> int
|
||||||
@@ -84,6 +85,63 @@ def test_int_locktime():
|
|||||||
assert Util.int_locktime() == 0
|
assert Util.int_locktime() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_days():
|
||||||
|
assert Util._relative_days("30d") == 30
|
||||||
|
assert Util._relative_days("1y") == 365
|
||||||
|
assert Util._relative_days("2y") == 730
|
||||||
|
assert Util._relative_days("30D") == 30
|
||||||
|
assert Util._relative_days(1700000000) is None
|
||||||
|
assert Util._relative_days("1700000000") is None
|
||||||
|
assert Util._relative_days("garbage") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_locktime_against_tx_absolute():
|
||||||
|
"""An absolute current date is returned unchanged (compared vs the tx)."""
|
||||||
|
frozen = 1817424000
|
||||||
|
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 = 1817424000 # 2027-08-05 00:00 UTC, 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 = 1817424000 # 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 = 1817424000
|
||||||
|
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 = 1817424000
|
||||||
|
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
|
||||||
|
|
||||||
@@ -261,27 +319,6 @@ def test_anticipate_locktime():
|
|||||||
assert low >= 1
|
assert low >= 1
|
||||||
|
|
||||||
|
|
||||||
def test_cmp_locktime():
|
|
||||||
assert Util.cmp_locktime("30d", "30d") == 0
|
|
||||||
# Note: cmp_locktime may return nonzero or None for mismatched units
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_locktimes():
|
|
||||||
class FakeTx:
|
|
||||||
locktime = 1700000000
|
|
||||||
|
|
||||||
# will with single entry
|
|
||||||
will = {
|
|
||||||
"tx1": {"tx": FakeTx()},
|
|
||||||
}
|
|
||||||
locktimes = list(Util.get_locktimes(will))
|
|
||||||
assert 1700000000 in locktimes
|
|
||||||
assert len(locktimes) == 1
|
|
||||||
|
|
||||||
# empty will
|
|
||||||
assert list(Util.get_locktimes({})) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_lowest_locktimes():
|
def test_get_lowest_locktimes():
|
||||||
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
||||||
# 500000, 900000 are block-height (< THRESHOLD)
|
# 500000, 900000 are block-height (< THRESHOLD)
|
||||||
@@ -293,18 +330,6 @@ def test_get_lowest_locktimes():
|
|||||||
assert Util.get_lowest_locktimes([]) == ([], [])
|
assert Util.get_lowest_locktimes([]) == ([], [])
|
||||||
|
|
||||||
|
|
||||||
def test_get_will_spent_utxos():
|
|
||||||
class FakeTx:
|
|
||||||
def inputs(self): return [1, 2, 3]
|
|
||||||
|
|
||||||
will = {
|
|
||||||
"tx1": {"tx": FakeTx()},
|
|
||||||
"tx2": {"tx": FakeTx()},
|
|
||||||
}
|
|
||||||
utxos = Util.get_will_spent_utxos(will)
|
|
||||||
assert len(utxos) == 6 # 3 inputs * 2 txs
|
|
||||||
|
|
||||||
|
|
||||||
def test_utxo_to_str():
|
def test_utxo_to_str():
|
||||||
class FakeUtxo:
|
class FakeUtxo:
|
||||||
def to_str(self): return "txid:0"
|
def to_str(self): return "txid:0"
|
||||||
@@ -347,44 +372,44 @@ def test_in_utxo():
|
|||||||
|
|
||||||
|
|
||||||
def test_cmp_output():
|
def test_cmp_output():
|
||||||
class O:
|
class Obj:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
assert Util.cmp_output(O("a", 100), O("a", 100)) is True
|
assert Util.cmp_output(Obj("a", 100), Obj("a", 100)) is True
|
||||||
assert Util.cmp_output(O("a", 100), O("b", 100)) is False
|
assert Util.cmp_output(Obj("a", 100), Obj("b", 100)) is False
|
||||||
assert Util.cmp_output(O("a", 100), O("a", 200)) is False
|
assert Util.cmp_output(Obj("a", 100), Obj("a", 200)) is False
|
||||||
|
|
||||||
|
|
||||||
def test_in_output():
|
def test_in_output():
|
||||||
class O:
|
class Obj:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
outputs = [O("a", 100), O("b", 200)]
|
outputs = [Obj("a", 100), Obj("b", 200)]
|
||||||
assert Util.in_output(O("a", 100), outputs) is True
|
assert Util.in_output(Obj("a", 100), outputs) is True
|
||||||
assert Util.in_output(O("z", 999), outputs) is False
|
assert Util.in_output(Obj("z", 999), outputs) is False
|
||||||
assert Util.in_output(O("a", 100), []) is False
|
assert Util.in_output(Obj("a", 100), []) is False
|
||||||
|
|
||||||
|
|
||||||
def test_din_output():
|
def test_din_output():
|
||||||
class O:
|
class Obj:
|
||||||
def __init__(self, addr, val):
|
def __init__(self, addr, val):
|
||||||
self.address = addr
|
self.address = addr
|
||||||
self.value = val
|
self.value = val
|
||||||
|
|
||||||
outputs = [O("a", 100), O("b", 200)]
|
outputs = [Obj("a", 100), Obj("b", 200)]
|
||||||
|
|
||||||
# same amount AND same address
|
# same amount AND same address
|
||||||
same_amt, same_addr = Util.din_output(O("a", 100), outputs)
|
same_amt, same_addr = Util.din_output(Obj("a", 100), outputs)
|
||||||
assert same_amt is True and same_addr is True
|
assert same_amt is True and same_addr is True
|
||||||
|
|
||||||
# same amount but different address
|
# same amount but different address
|
||||||
same_amt, same_addr = Util.din_output(O("c", 100), outputs)
|
same_amt, same_addr = Util.din_output(Obj("c", 100), outputs)
|
||||||
assert same_amt is True and same_addr is False
|
assert same_amt is True and same_addr is False
|
||||||
|
|
||||||
# different amount
|
# different amount
|
||||||
same_amt, same_addr = Util.din_output(O("z", 999), outputs)
|
same_amt, same_addr = Util.din_output(Obj("z", 999), outputs)
|
||||||
assert same_amt is False and same_addr is False
|
assert same_amt is False and same_addr is False
|
||||||
|
|
||||||
|
|
||||||
@@ -439,6 +464,13 @@ if __name__ == "__main__":
|
|||||||
test_str_to_locktime()
|
test_str_to_locktime()
|
||||||
test_parse_locktime_string()
|
test_parse_locktime_string()
|
||||||
test_int_locktime()
|
test_int_locktime()
|
||||||
|
test_relative_days()
|
||||||
|
test_resolve_locktime_against_tx_absolute()
|
||||||
|
test_resolve_locktime_against_tx_unchanged_relative()
|
||||||
|
test_resolve_locktime_against_tx_lengthened()
|
||||||
|
test_resolve_locktime_against_tx_shortened()
|
||||||
|
test_resolve_locktime_against_tx_no_relative_anchor()
|
||||||
|
test_resolve_locktime_against_tx_zero_tx_locktime()
|
||||||
test_encode_decode_amount()
|
test_encode_decode_amount()
|
||||||
test_is_perc()
|
test_is_perc()
|
||||||
test_cmp_array()
|
test_cmp_array()
|
||||||
@@ -453,10 +485,7 @@ if __name__ == "__main__":
|
|||||||
test_get_value_amount()
|
test_get_value_amount()
|
||||||
test_chk_locktime()
|
test_chk_locktime()
|
||||||
test_anticipate_locktime()
|
test_anticipate_locktime()
|
||||||
test_cmp_locktime()
|
|
||||||
test_get_locktimes()
|
|
||||||
test_get_lowest_locktimes()
|
test_get_lowest_locktimes()
|
||||||
test_get_will_spent_utxos()
|
|
||||||
test_utxo_to_str()
|
test_utxo_to_str()
|
||||||
test_cmp_utxo()
|
test_cmp_utxo()
|
||||||
test_in_utxo()
|
test_in_utxo()
|
||||||
|
|||||||
@@ -8,13 +8,19 @@ Run:
|
|||||||
python3 tests/test_core_will.py
|
python3 tests/test_core_will.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
import copy
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import WillItem, Will
|
from bal.core.checkalive import resolve_date_to_check
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.util import copy_structure
|
||||||
|
from bal.core.will import (
|
||||||
|
HeirNotFoundException,
|
||||||
|
NoHeirsException,
|
||||||
|
Will,
|
||||||
|
WillItem,
|
||||||
|
)
|
||||||
|
|
||||||
# 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 = (
|
||||||
@@ -48,7 +54,7 @@ def _make_willitem_blank():
|
|||||||
"""Create a fresh WillItem from scratch."""
|
"""Create a fresh WillItem from scratch."""
|
||||||
item = WillItem(_make_minimal_willitem_dict())
|
item = WillItem(_make_minimal_willitem_dict())
|
||||||
# Reset STATUS to clean defaults
|
# Reset STATUS to clean defaults
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -126,22 +132,6 @@ def test_willitem_str_repr():
|
|||||||
# Will static methods
|
# Will static methods
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_will_get_sorted_will():
|
|
||||||
# Use a simple dict structure that will[key]["tx"].locktime works
|
|
||||||
class FakeTx:
|
|
||||||
def __init__(self, locktime):
|
|
||||||
self.locktime = locktime
|
|
||||||
|
|
||||||
will = {
|
|
||||||
"b": {"tx": FakeTx(200)},
|
|
||||||
"a": {"tx": FakeTx(100)},
|
|
||||||
}
|
|
||||||
sorted_will = Will.get_sorted_will(will)
|
|
||||||
assert len(sorted_will) == 2
|
|
||||||
assert sorted_will[0][1]["tx"].locktime == 100
|
|
||||||
assert sorted_will[1][1]["tx"].locktime == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_will_only_valid():
|
def test_will_only_valid():
|
||||||
item1 = _make_willitem_blank()
|
item1 = _make_willitem_blank()
|
||||||
item2 = _make_willitem_blank()
|
item2 = _make_willitem_blank()
|
||||||
@@ -167,8 +157,8 @@ def test_will_only_valid_list():
|
|||||||
def _make_will_with_heirs(heirs, tx_locktime):
|
def _make_will_with_heirs(heirs, tx_locktime):
|
||||||
"""Build a single-item will whose stored heirs == ``heirs`` and whose
|
"""Build a single-item will whose stored heirs == ``heirs`` and whose
|
||||||
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
|
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
|
||||||
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
|
item = WillItem(_make_minimal_willitem_dict(heirs=copy_structure(heirs)))
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = tx_locktime
|
item.tx.locktime = tx_locktime
|
||||||
return {"willid_1": item}
|
return {"willid_1": item}
|
||||||
|
|
||||||
@@ -179,7 +169,7 @@ def test_check_heirs_unchanged_is_coherent():
|
|||||||
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||||
will = _make_will_with_heirs(heirs, lt)
|
will = _make_will_with_heirs(heirs, lt)
|
||||||
result = Will.check_willexecutors_and_heirs(
|
result = Will.check_willexecutors_and_heirs(
|
||||||
will, copy.deepcopy(heirs), {}, False, 0, 100
|
will, copy_structure(heirs), {}, False, 0, 100
|
||||||
)
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@@ -226,6 +216,59 @@ def test_check_heir_added_triggers_rebuild():
|
|||||||
assert raised, "adding an heir must raise HeirNotFoundException"
|
assert raised, "adding an heir must raise HeirNotFoundException"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shortened_relative_recipe_on_signed_rebuilds_not_noheirs():
|
||||||
|
"""Regression (karen7): heirs shortened "2y"->"1y" on a signed will whose
|
||||||
|
ADVANCED check window is anchored to the frozen built delivery must trigger
|
||||||
|
a plain rebuild (HeirNotFoundException), NOT "No Heirs".
|
||||||
|
|
||||||
|
Earlier the count gate resolved each current relative recipe from *now*
|
||||||
|
while ``check_date`` was anchored to the (longer) frozen built locktime, so
|
||||||
|
every heir fell below the window and was silently excluded -> NoHeirs even
|
||||||
|
though the will simply needs rebuilding on the new, shorter schedule."""
|
||||||
|
lt = 2_100_000_000 # a far-future frozen delivery (a "2y" build)
|
||||||
|
will_heirs = {"alice": ["addr_alice", 5000, "2y"]}
|
||||||
|
current_heirs = {"alice": ["addr_alice", 5000, "1y"]}
|
||||||
|
will = _make_will_with_heirs(will_heirs, lt)
|
||||||
|
will["willid_1"].set_status("COMPLETE", True)
|
||||||
|
check_date = resolve_date_to_check(
|
||||||
|
False, {"locktime": "2y", "threshold": "-150d"}, built_locktime=lt
|
||||||
|
)
|
||||||
|
assert check_date < lt # the anchored window really precedes the delivery
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
Will.check_willexecutors_and_heirs(
|
||||||
|
will, copy_structure(current_heirs), {}, False, check_date, 100
|
||||||
|
)
|
||||||
|
except HeirNotFoundException:
|
||||||
|
raised = "rebuild"
|
||||||
|
except NoHeirsException:
|
||||||
|
raised = "noheirs"
|
||||||
|
assert raised == "rebuild", (
|
||||||
|
f"shortened recipe on a signed will must rebuild, got {raised!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_heirs_past_check_date_still_noheirs():
|
||||||
|
"""The "no valid heirs" gate is preserved: when every heir is coherent with
|
||||||
|
the built will but its delivery lies before ``check_date``, the check still
|
||||||
|
reports NoHeirsException (there is literally nothing future to inherit)."""
|
||||||
|
lt = 1_900_000_000
|
||||||
|
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
|
||||||
|
will = _make_will_with_heirs(will_heirs, lt)
|
||||||
|
raised = None
|
||||||
|
try:
|
||||||
|
Will.check_willexecutors_and_heirs(
|
||||||
|
will, copy_structure(will_heirs), {}, False, lt + 86400, 100
|
||||||
|
)
|
||||||
|
except HeirNotFoundException:
|
||||||
|
raised = "rebuild"
|
||||||
|
except NoHeirsException:
|
||||||
|
raised = "noheirs"
|
||||||
|
assert raised == "noheirs", (
|
||||||
|
f"a fully delivered will must report NoHeirs, got {raised!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_needs_server_check():
|
def test_needs_server_check():
|
||||||
"""Check button selection logic: only a VALID, PUSHED will with a
|
"""Check button selection logic: only a VALID, PUSHED will with a
|
||||||
will-executor that is not yet CHECKED must be queried on the server.
|
will-executor that is not yet CHECKED must be queried on the server.
|
||||||
@@ -332,11 +375,19 @@ def test_will_check_tx_height():
|
|||||||
|
|
||||||
def test_exceptions():
|
def test_exceptions():
|
||||||
from bal.core.will import (
|
from bal.core.will import (
|
||||||
WillException, WillExpiredException, NotCompleteWillException,
|
AmountException,
|
||||||
HeirChangeException, TxFeesChangedException, HeirNotFoundException,
|
FixedAmountException,
|
||||||
WillexecutorChangeException, NoWillExecutorNotPresent,
|
HeirChangeException,
|
||||||
WillExecutorNotPresent, NoHeirsException,
|
HeirNotFoundException,
|
||||||
AmountException, PercAmountException, FixedAmountException,
|
NoHeirsException,
|
||||||
|
NotCompleteWillException,
|
||||||
|
NoWillExecutorNotPresent,
|
||||||
|
PercAmountException,
|
||||||
|
TxFeesChangedException,
|
||||||
|
WillException,
|
||||||
|
WillexecutorChangeException,
|
||||||
|
WillExecutorNotPresent,
|
||||||
|
WillExpiredException,
|
||||||
WillPostponedException,
|
WillPostponedException,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -375,4 +426,4 @@ if __name__ == "__main__":
|
|||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
globals()[name]()
|
globals()[name]()
|
||||||
print(f" [OK] {name}")
|
print(f" [OK] {name}")
|
||||||
print(f"[OK] All Will tests passed")
|
print("[OK] All Will tests passed")
|
||||||
|
|||||||
@@ -8,14 +8,28 @@ Run:
|
|||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock, patch, PropertyMock, call
|
import sys
|
||||||
|
from binascii import unhexlify
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
|
from electrum import crypto
|
||||||
|
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
|
||||||
|
from electrum.bitcoin import public_key_to_p2wpkh
|
||||||
|
from electrum.descriptor import parse_descriptor
|
||||||
|
from electrum.transaction import (
|
||||||
|
PartialTransaction,
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
Sighash,
|
||||||
|
Transaction,
|
||||||
|
TxOutpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bal.core.util import Util
|
||||||
from bal.core.will import Will, WillItem
|
from bal.core.will import Will, WillItem
|
||||||
from electrum.transaction import Transaction
|
|
||||||
|
|
||||||
_VALID_TX_HEX = (
|
_VALID_TX_HEX = (
|
||||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
@@ -170,6 +184,8 @@ def test_mempool_status_clears_valid():
|
|||||||
def test_check_invalidated_invalidated():
|
def test_check_invalidated_invalidated():
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
|
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
|
||||||
|
# The funding is really consumed by a broadcast tx -> the will is dead.
|
||||||
|
wallet.adb.get_spender.return_value = "ab" * 32
|
||||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
"willexecutor": None, "status": "", "description": "",
|
"willexecutor": None, "status": "", "description": "",
|
||||||
"time": 0, "change": "", "baltx_fees": 100})
|
"time": 0, "change": "", "baltx_fees": 100})
|
||||||
@@ -185,6 +201,9 @@ def test_check_invalidated_invalidated():
|
|||||||
def test_check_will():
|
def test_check_will():
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
||||||
|
# No broadcast tx spends the funding: the missing UTXO is only a local
|
||||||
|
# (history) artifact, so the will is not invalidated by it.
|
||||||
|
wallet.adb.get_spender.return_value = None
|
||||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
"willexecutor": None, "status": "", "description": "",
|
"willexecutor": None, "status": "", "description": "",
|
||||||
"time": 0, "change": "", "baltx_fees": 100})
|
"time": 0, "change": "", "baltx_fees": 100})
|
||||||
@@ -196,6 +215,141 @@ def test_check_will():
|
|||||||
assert item.get_status("MEMPOOL") is True
|
assert item.get_status("MEMPOOL") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillItem signature counts + PARTIALLY_SIGNED status
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _multisig_descriptor():
|
||||||
|
"""Return a 2-of-3 wsh multisig descriptor and its three pubkeys."""
|
||||||
|
pubs = []
|
||||||
|
for seed in (1, 2, 3):
|
||||||
|
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
|
||||||
|
return parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs))), pubs
|
||||||
|
|
||||||
|
|
||||||
|
def _make_multisig_ptx(nsigs, locktime=None):
|
||||||
|
"""A 2-of-3 wsh multisig PartialTransaction carrying ``nsigs`` signatures."""
|
||||||
|
desc, pubs = _multisig_descriptor()
|
||||||
|
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
|
||||||
|
txin.script_descriptor = desc
|
||||||
|
txin._trusted_value_sats = 100000
|
||||||
|
txin.sighash = Sighash.ALL
|
||||||
|
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
|
||||||
|
for i in range(nsigs):
|
||||||
|
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
|
||||||
|
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
|
||||||
|
txout = PartialTxOutput.from_address_and_value(addr, 50000)
|
||||||
|
ptx = PartialTransaction()
|
||||||
|
if locktime is not None:
|
||||||
|
ptx.locktime = locktime
|
||||||
|
ptx.add_inputs([txin])
|
||||||
|
ptx.add_outputs([txout])
|
||||||
|
return ptx
|
||||||
|
|
||||||
|
|
||||||
|
def _make_multisig_willitem(nsig):
|
||||||
|
"""A WillItem wrapping an unsigned 2-of-3 partial tx with ``nsig`` sigs.
|
||||||
|
|
||||||
|
The script descriptor is re-attached after the WillItem round-trips the tx
|
||||||
|
through serialization (PSBT serialization drops the descriptor but keeps
|
||||||
|
the signatures), mirroring the real load-from-wallet flow.
|
||||||
|
"""
|
||||||
|
w = {"tx": _make_multisig_ptx(nsig), "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
|
"willexecutor": None, "status": "", "description": "",
|
||||||
|
"time": 0, "change": "", "baltx_fees": 100}
|
||||||
|
item = WillItem(w, _id="mswill")
|
||||||
|
item.tx.inputs()[0].script_descriptor = _multisig_descriptor()[0]
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_sigs_fields_roundtrip():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
assert item.sigs_required == 0
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 1
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
d = item.to_dict()
|
||||||
|
assert d["sigs_required"] == 2
|
||||||
|
assert d["sigs_have"] == 1
|
||||||
|
assert d["PARTIALLY_SIGNED"] is True
|
||||||
|
item2 = WillItem(d, _id="mswill")
|
||||||
|
assert item2.sigs_required == 2
|
||||||
|
assert item2.sigs_have == 1
|
||||||
|
assert item2.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_legacy_dict_defaults_sig_fields():
|
||||||
|
# A will saved before the signature-tracking feature has no sig fields:
|
||||||
|
# they must default to 0 and the flag to False.
|
||||||
|
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||||
|
"willexecutor": None, "status": "", "description": "",
|
||||||
|
"time": 0, "change": "", "baltx_fees": 100}, _id="legacy")
|
||||||
|
assert item.sigs_required == 0
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_partial_signed_keeps_valid():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
assert "Partially Signed" in item.status
|
||||||
|
|
||||||
|
|
||||||
|
def test_willitem_complete_clears_partially_signed():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
item.set_status("COMPLETE", True)
|
||||||
|
assert item.get_status("COMPLETE") is True
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_partial():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.sigs_have == 1
|
||||||
|
assert item.sigs_required == 2
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is True
|
||||||
|
assert item.get_status("VALID") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_unsigned_not_partial():
|
||||||
|
item = _make_multisig_willitem(0)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
assert item.sigs_required == 2
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_fully_signed_clears_flag():
|
||||||
|
item = _make_multisig_willitem(2)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_complete_item_clears_flag():
|
||||||
|
item = _make_multisig_willitem(1)
|
||||||
|
item.set_status("PARTIALLY_SIGNED", True)
|
||||||
|
item.set_status("COMPLETE", True)
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_signatures_single_sig_required():
|
||||||
|
# A single-signature (P2WPKH) will needs exactly 1 signature: 0 present is
|
||||||
|
# "New", not "partially signed".
|
||||||
|
pub = crypto.privkey_to_pubkey(bytes([7] * 32)).hex()
|
||||||
|
item = _make_multisig_willitem(0)
|
||||||
|
item.tx.inputs()[0].script_descriptor = parse_descriptor("wpkh({})".format(pub))
|
||||||
|
Will.check_signatures({"mswill": item})
|
||||||
|
assert item.sigs_required == 1
|
||||||
|
assert item.sigs_have == 0
|
||||||
|
assert item.get_status("PARTIALLY_SIGNED") is False
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# WillItem.__init__ with wallet
|
# WillItem.__init__ with wallet
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -217,6 +371,545 @@ 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)
|
||||||
|
# Simulate the real adb: dropping a stored tx frees the outputs it spent.
|
||||||
|
for utxos in self.outputs.values():
|
||||||
|
for utxo in utxos.values():
|
||||||
|
if getattr(utxo, "spent_txid", None) == txid:
|
||||||
|
utxo.spent_txid = None
|
||||||
|
utxo.spent_height = None
|
||||||
|
|
||||||
|
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) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Will.remove_stale_wallet_history (pre-build history purge)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_frees_equal_locktime_spend():
|
||||||
|
# The stale placeholders (saved by a previous prepare) have the SAME
|
||||||
|
# locktime as the will being rebuilt, so get_available_utxos does NOT
|
||||||
|
# restore their coins (see test_...does_not_restore_not_later_locktime).
|
||||||
|
# The pre-build purge deletes them and the coins become available again.
|
||||||
|
wallet, utxo = _wallet_with_local_spend(locktime=1000)
|
||||||
|
spender = "ab" * 32
|
||||||
|
assert Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000) == []
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == [spender]
|
||||||
|
assert wallet.adb.removed == [spender]
|
||||||
|
assert spender not in wallet.labels
|
||||||
|
assert [
|
||||||
|
u.prevout.to_str()
|
||||||
|
for u in Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
|
||||||
|
] == [utxo.prevout.to_str()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_keeps_confirmed_spender():
|
||||||
|
# A broadcast (confirmed) BAL-labelled tx is never purged.
|
||||||
|
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
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == []
|
||||||
|
assert wallet.adb.removed == []
|
||||||
|
assert wallet.labels[spender] == _HISTORY_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_keeps_unlabeled_local_spender():
|
||||||
|
# Wallet-local BAL-status tx without a matching history label stays.
|
||||||
|
wallet, _ = _wallet_with_local_spend(locktime=1000)
|
||||||
|
spender = "ab" * 32
|
||||||
|
wallet.labels[spender] = "some other label"
|
||||||
|
removed = Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE)
|
||||||
|
assert removed == []
|
||||||
|
assert wallet.adb.removed == []
|
||||||
|
assert wallet.labels[spender] == "some other label"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_noop_without_wallet_or_adb():
|
||||||
|
assert Will.remove_stale_wallet_history(None, _HISTORY_TEMPLATE) == []
|
||||||
|
wallet = FakeWallet()
|
||||||
|
wallet.adb = None
|
||||||
|
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stale_wallet_history_never_raises():
|
||||||
|
adb = MagicMock()
|
||||||
|
adb.get_tx_height.side_effect = RuntimeError("boom")
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.adb = adb
|
||||||
|
wallet.get_all_labels.return_value = {"ab" * 32: _HISTORY_LABEL}
|
||||||
|
assert Will.remove_stale_wallet_history(wallet, _HISTORY_TEMPLATE) == []
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
485
tests/test_core_will_invalidate.py
Normal file
485
tests/test_core_will_invalidate.py
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
"""
|
||||||
|
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 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 = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
|
# Set the input value so the balance calculation works.
|
||||||
|
# Use the name-mangled attribute because tx_from_any creates a
|
||||||
|
# Transaction whose inputs are TxInput objects; TxInput.value_sats()
|
||||||
|
# reads __value_sats, not _trusted_value_sats.
|
||||||
|
item.tx.inputs()[0]._TxInput__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(value_sats=100)])
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet, fees_per_byte=100)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
# from_io is still called once (for fee estimation), but the
|
||||||
|
# result is discarded because balance - fee <= 0.
|
||||||
|
assert mock_from_io.call_count == 1
|
||||||
|
|
||||||
|
def test_only_valid_items_contribute_balance(self):
|
||||||
|
"""INVALIDATED will items are excluded from the balance."""
|
||||||
|
valid_item = _make_willitem(value_sats=1000000, valid=True)
|
||||||
|
invalid_item = _make_willitem(value_sats=2000000, valid=False)
|
||||||
|
will = {"valid": valid_item, "invalid": invalid_item}
|
||||||
|
wallet = _mock_wallet([_make_utxo()])
|
||||||
|
|
||||||
|
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
# Balance = 1000000 (valid only), fee = 200 * 10 = 2000
|
||||||
|
# Output value = 998000
|
||||||
|
second_call_value = mock_out.call_args_list[1][0][1]
|
||||||
|
assert second_call_value == 998000
|
||||||
|
|
||||||
|
def test_first_from_io_uses_full_balance(self):
|
||||||
|
"""The first from_io call uses the full balance (before fee deduction)
|
||||||
|
to estimate the fee."""
|
||||||
|
item = _make_willitem(value_sats=1000000)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
wallet = _mock_wallet([_make_utxo()])
|
||||||
|
|
||||||
|
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=10)
|
||||||
|
|
||||||
|
# First from_address_and_value call: value = balance (1000000)
|
||||||
|
first_call_value = mock_out.call_args_list[0][0][1]
|
||||||
|
assert first_call_value == 1000000
|
||||||
|
|
||||||
|
def test_output_address_is_change_address(self):
|
||||||
|
"""The invalidation output goes to the wallet's change address."""
|
||||||
|
item = _make_willitem(value_sats=1000000)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
wallet = _mock_wallet([_make_utxo()])
|
||||||
|
|
||||||
|
_, _, mock_out = _run_invalidate(will, wallet)
|
||||||
|
|
||||||
|
# Both calls to from_address_and_value use the change address.
|
||||||
|
for call in mock_out.call_args_list:
|
||||||
|
assert call[0][0] == _CHANGE_ADDR
|
||||||
|
|
||||||
|
def test_multiple_utxos_all_matched(self):
|
||||||
|
"""Multiple matching UTXOs are all included in the invalidation."""
|
||||||
|
item1 = _make_willitem(value_sats=500000)
|
||||||
|
item2 = _make_willitem(value_sats=300000)
|
||||||
|
will = {"tx1": item1, "tx2": item2}
|
||||||
|
|
||||||
|
# Two UTXOs with different prevouts matching the two will items.
|
||||||
|
# Since both items use the same _VALID_TX_HEX, their prevout is the
|
||||||
|
# same. To test multiple UTXOs, we need a second tx hex with a
|
||||||
|
# different input.
|
||||||
|
#
|
||||||
|
# However, get_all_inputs deduplicates by prevout_str, so even with
|
||||||
|
# two items sharing the same prevout, only one entry is added to
|
||||||
|
# prevout_to_spend. The first matching UTXO is what matters.
|
||||||
|
utxos = [_make_utxo()]
|
||||||
|
wallet = _mock_wallet(utxos)
|
||||||
|
|
||||||
|
result, mock_from_io, _ = _run_invalidate(will, wallet)
|
||||||
|
assert result is not None
|
||||||
|
# Only 1 UTXO spent (deduplication of shared prevout)
|
||||||
|
spent_utxos = mock_from_io.call_args[0][0]
|
||||||
|
assert len(spent_utxos) == 1
|
||||||
|
|
||||||
|
def test_zero_fees_per_byte(self):
|
||||||
|
"""With zero fee rate, the full balance goes to the output."""
|
||||||
|
item = _make_willitem(value_sats=1000000)
|
||||||
|
will = {"willtxid1": item}
|
||||||
|
wallet = _mock_wallet([_make_utxo()])
|
||||||
|
|
||||||
|
_, mock_from_io, mock_out = _run_invalidate(will, wallet, fees_per_byte=0)
|
||||||
|
|
||||||
|
assert mock_from_io.call_count == 2 # two calls (both succeed)
|
||||||
|
# Output value = balance - 0 = 1000000
|
||||||
|
second_call_value = mock_out.call_args_list[1][0][1]
|
||||||
|
assert second_call_value == 1000000
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================== #
|
||||||
|
# Will.set_invalidate - status flag cascade
|
||||||
|
# ================================================================== #
|
||||||
|
|
||||||
|
class TestSetInvalidate:
|
||||||
|
"""Tests for ``Will.set_invalidate()``: marking will items as invalidated."""
|
||||||
|
|
||||||
|
def test_single_item_no_children(self):
|
||||||
|
"""Invalidating a single will item sets INVALIDATED and clears VALID."""
|
||||||
|
item = _make_willitem(valid=True)
|
||||||
|
item.children = {}
|
||||||
|
will = {"willid1": item}
|
||||||
|
|
||||||
|
Will.set_invalidate("willid1", will)
|
||||||
|
|
||||||
|
assert item.get_status("INVALIDATED") is True
|
||||||
|
assert item.get_status("VALID") is False
|
||||||
|
|
||||||
|
def test_cascades_to_direct_children(self):
|
||||||
|
"""Invalidating a parent cascades INVALIDATED to its children."""
|
||||||
|
parent = _make_willitem(valid=True)
|
||||||
|
child = _make_willitem(valid=True)
|
||||||
|
|
||||||
|
parent.children = {"child_id": ["child_id", 0, 0]}
|
||||||
|
child.children = {}
|
||||||
|
|
||||||
|
will = {"parent_id": parent, "child_id": child}
|
||||||
|
|
||||||
|
Will.set_invalidate("parent_id", will)
|
||||||
|
|
||||||
|
assert parent.get_status("INVALIDATED") is True
|
||||||
|
assert parent.get_status("VALID") is False
|
||||||
|
assert child.get_status("INVALIDATED") is True
|
||||||
|
assert child.get_status("VALID") is False
|
||||||
|
|
||||||
|
def test_cascades_to_grandchildren(self):
|
||||||
|
"""Invalidating cascades through multiple levels of descendants."""
|
||||||
|
root = _make_willitem(valid=True)
|
||||||
|
branch = _make_willitem(valid=True)
|
||||||
|
leaf = _make_willitem(valid=True)
|
||||||
|
|
||||||
|
root.children = {"branch_id": ["branch_id", 0, 0]}
|
||||||
|
branch.children = {"leaf_id": ["leaf_id", 0, 0]}
|
||||||
|
leaf.children = {}
|
||||||
|
|
||||||
|
will = {
|
||||||
|
"root_id": root,
|
||||||
|
"branch_id": branch,
|
||||||
|
"leaf_id": leaf,
|
||||||
|
}
|
||||||
|
|
||||||
|
Will.set_invalidate("root_id", will)
|
||||||
|
|
||||||
|
for name, item in [("root", root), ("branch", branch), ("leaf", leaf)]:
|
||||||
|
assert item.get_status("INVALIDATED") is True, f"{name} should be INVALIDATED"
|
||||||
|
assert item.get_status("VALID") is False, f"{name} should not be VALID"
|
||||||
|
|
||||||
|
def test_empty_children_dict(self):
|
||||||
|
"""A will item with an empty children dict is a leaf (no cascade)."""
|
||||||
|
item = _make_willitem(valid=True)
|
||||||
|
item.children = {}
|
||||||
|
will = {"wid": item}
|
||||||
|
|
||||||
|
Will.set_invalidate("wid", will)
|
||||||
|
|
||||||
|
assert item.get_status("INVALIDATED") is True
|
||||||
|
assert item.get_status("VALID") is False
|
||||||
|
|
||||||
|
def test_does_not_affect_siblings(self):
|
||||||
|
"""Invalidating one item does not affect unrelated siblings."""
|
||||||
|
item_a = _make_willitem(valid=True)
|
||||||
|
item_b = _make_willitem(valid=True)
|
||||||
|
|
||||||
|
item_a.children = {}
|
||||||
|
item_b.children = {}
|
||||||
|
|
||||||
|
will = {"a": item_a, "b": item_b}
|
||||||
|
|
||||||
|
Will.set_invalidate("a", will)
|
||||||
|
|
||||||
|
assert item_a.get_status("INVALIDATED") is True
|
||||||
|
assert item_a.get_status("VALID") is False
|
||||||
|
assert item_b.get_status("INVALIDATED") is False
|
||||||
|
assert item_b.get_status("VALID") is True
|
||||||
|
|
||||||
|
def test_multiple_children(self):
|
||||||
|
"""Invalidating a parent with multiple children cascades to all of them."""
|
||||||
|
parent = _make_willitem(valid=True)
|
||||||
|
child1 = _make_willitem(valid=True)
|
||||||
|
child2 = _make_willitem(valid=True)
|
||||||
|
|
||||||
|
parent.children = {
|
||||||
|
"c1": ["c1", 0, 0],
|
||||||
|
"c2": ["c2", 0, 0],
|
||||||
|
}
|
||||||
|
child1.children = {}
|
||||||
|
child2.children = {}
|
||||||
|
|
||||||
|
will = {"p": parent, "c1": child1, "c2": child2}
|
||||||
|
|
||||||
|
Will.set_invalidate("p", will)
|
||||||
|
|
||||||
|
assert parent.get_status("INVALIDATED") is True
|
||||||
|
assert child1.get_status("INVALIDATED") is True
|
||||||
|
assert child2.get_status("INVALIDATED") is True
|
||||||
|
|
||||||
|
def test_idempotent(self):
|
||||||
|
"""Setting INVALIDATED twice on the same item is a safe no-op."""
|
||||||
|
item = _make_willitem(valid=True)
|
||||||
|
item.children = {}
|
||||||
|
will = {"wid": item}
|
||||||
|
|
||||||
|
Will.set_invalidate("wid", will)
|
||||||
|
Will.set_invalidate("wid", will)
|
||||||
|
|
||||||
|
assert item.get_status("INVALIDATED") is True
|
||||||
|
assert item.get_status("VALID") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All invalidation tests passed")
|
||||||
@@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
from bal.core.willexecutors import Willexecutors
|
from bal.core.willexecutors import Willexecutors
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
|||||||
|
|
||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -65,6 +64,47 @@ def test_editable_dates_can_be_enabled():
|
|||||||
assert BalConfig(cfg, "bal_editable_dates", False).get() is True
|
assert BalConfig(cfg, "bal_editable_dates", False).get() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# History persistence settings (SAVE_HISTORY / HISTORY_LABEL)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_save_history_defaults_on():
|
||||||
|
"""History persistence is opt-out: the flag defaults to ON."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
save_history = BalConfig(cfg, "bal_save_history", True)
|
||||||
|
assert save_history.get() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_history_can_be_disabled_and_read_back():
|
||||||
|
cfg = FakeConfig()
|
||||||
|
save_history = BalConfig(cfg, "bal_save_history", True)
|
||||||
|
save_history.set(False)
|
||||||
|
assert BalConfig(cfg, "bal_save_history", True).get() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_default_template():
|
||||||
|
"""The default label contains the {willexecutor} variable."""
|
||||||
|
cfg = FakeConfig()
|
||||||
|
history_label = BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
assert history_label.get() == (
|
||||||
|
"BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
assert "{willexecutor}" in history_label.get()
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_label_can_be_changed_and_read_back():
|
||||||
|
cfg = FakeConfig()
|
||||||
|
history_label = BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
history_label.set("My custom label for {willexecutor}")
|
||||||
|
assert BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
).get() == "My custom label for {willexecutor}"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# C4b - Reset to defaults
|
# C4b - Reset to defaults
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -83,9 +123,10 @@ def _reset_to_defaults(configs):
|
|||||||
def test_reset_restores_all_dialog_settings():
|
def test_reset_restores_all_dialog_settings():
|
||||||
"""C4b: Reset restores every dialog setting to its factory default.
|
"""C4b: Reset restores every dialog setting to its factory default.
|
||||||
|
|
||||||
The dialog exposes seven settings: the original six plus the Group C
|
The dialog exposes nine settings: the original six, the Group C
|
||||||
"Editable dates" checkbox, which the Reset button must also restore (this
|
"Editable dates" checkbox, and the Group H "Save inheritance transactions
|
||||||
was a follow-up fix after the first test round).
|
in history" checkbox + "History label" field, which the Reset button must
|
||||||
|
also restore.
|
||||||
"""
|
"""
|
||||||
cfg = FakeConfig()
|
cfg = FakeConfig()
|
||||||
|
|
||||||
@@ -103,6 +144,10 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
"bal_event_description",
|
"bal_event_description",
|
||||||
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
|
||||||
)
|
)
|
||||||
|
save_history = BalConfig(cfg, "bal_save_history", True)
|
||||||
|
history_label = BalConfig(
|
||||||
|
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
settings = [
|
settings = [
|
||||||
hide_replaced,
|
hide_replaced,
|
||||||
hide_invalidated,
|
hide_invalidated,
|
||||||
@@ -111,6 +156,8 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
calendar_app,
|
calendar_app,
|
||||||
event_summary,
|
event_summary,
|
||||||
event_description,
|
event_description,
|
||||||
|
save_history,
|
||||||
|
history_label,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Mutate every setting away from its default.
|
# Mutate every setting away from its default.
|
||||||
@@ -121,11 +168,15 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
calendar_app.set("/custom/app")
|
calendar_app.set("/custom/app")
|
||||||
event_summary.set("custom summary")
|
event_summary.set("custom summary")
|
||||||
event_description.set("custom description")
|
event_description.set("custom description")
|
||||||
|
save_history.set(False)
|
||||||
|
history_label.set("custom label")
|
||||||
|
|
||||||
# Sanity: the values really changed.
|
# Sanity: the values really changed.
|
||||||
assert hide_replaced.get() is False
|
assert hide_replaced.get() is False
|
||||||
assert editable_dates.get() is True
|
assert editable_dates.get() is True
|
||||||
assert calendar_app.get() == "/custom/app"
|
assert calendar_app.get() == "/custom/app"
|
||||||
|
assert save_history.get() is False
|
||||||
|
assert history_label.get() == "custom label"
|
||||||
|
|
||||||
# Reset and verify each one is back to its declared default.
|
# Reset and verify each one is back to its declared default.
|
||||||
_reset_to_defaults(settings)
|
_reset_to_defaults(settings)
|
||||||
@@ -133,6 +184,11 @@ def test_reset_restores_all_dialog_settings():
|
|||||||
assert s.get() == s.default
|
assert s.get() == s.default
|
||||||
# In particular the "Editable dates" flag is back OFF.
|
# In particular the "Editable dates" flag is back OFF.
|
||||||
assert editable_dates.get() is False
|
assert editable_dates.get() is False
|
||||||
|
# And the history persistence flag is back ON with the default template.
|
||||||
|
assert save_history.get() is True
|
||||||
|
assert history_label.get() == (
|
||||||
|
"BitcoinAfterLife inheritance transaction - {willexecutor}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_reset_does_not_touch_unrelated_settings():
|
def test_reset_does_not_touch_unrelated_settings():
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ Covered behaviour:
|
|||||||
most one reminder per available day, and never returns more reminders than
|
most one reminder per available day, and never returns more reminders than
|
||||||
requested.
|
requested.
|
||||||
|
|
||||||
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
|
||||||
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
so these tests run without Qt (or Electrum) at all.
|
||||||
the other GUI tests.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
python3 -m pytest tests/test_group_d_alarms.py -q
|
python3 tests/test_group_d_alarms.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -25,8 +24,7 @@ import sys
|
|||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.plugin_base import BalConfig
|
from bal.core.plugin_base import BalConfig
|
||||||
from bal.gui.qt.widgets import compute_reminder_offsets
|
from bal.core.reminders import compute_reminder_offsets
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Mocks
|
# Mocks
|
||||||
|
|||||||
397
tests/test_group_e_karen7_invalidate.py
Normal file
397
tests/test_group_e_karen7_invalidate.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
"""
|
||||||
|
Group E - karen7 wallet: build the inheritance then generate the
|
||||||
|
cancellation (invalidation) transaction.
|
||||||
|
|
||||||
|
This test exercises the full pipeline with REAL Electrum transaction
|
||||||
|
building (no mocking of from_io, from_address_and_value, or is_address):
|
||||||
|
|
||||||
|
1. Load the karen7 regtest wallet (heirs + UTXOs).
|
||||||
|
2. Set Electrum to regtest mode so bcrt1q addresses validate.
|
||||||
|
3. Build the inheritance transactions via ``Heirs.buildTransactions``
|
||||||
|
using real ``PartialTransaction.from_io`` and real
|
||||||
|
``PartialTxOutput.from_address_and_value``.
|
||||||
|
4. Wrap each built transaction into a ``WillItem`` with VALID status.
|
||||||
|
5. Populate ``_trusted_value_sats`` on each input (what
|
||||||
|
``add_info_from_wallet`` does in the real flow).
|
||||||
|
6. Call ``Will.invalidate_will()`` to generate the cancellation tx.
|
||||||
|
7. Assert that the cancellation tx is well-formed.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||||
|
python3 -m pytest tests/test_group_e_karen7_invalidate.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
import 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.util import copy_structure
|
||||||
|
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_structure(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 six heirs."""
|
||||||
|
assert len(self.heirs_model) == 6
|
||||||
|
assert list(self.heirs_model.keys()) == [
|
||||||
|
"aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
@@ -22,7 +22,6 @@ Run:
|
|||||||
python3 -m pytest tests/test_group_e_mock_karen7.py -q
|
python3 -m pytest tests/test_group_e_mock_karen7.py -q
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -30,18 +29,22 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
# Make the plugin package importable when run directly (tests/ is one level
|
# Make the plugin package importable when run directly (tests/ is one level
|
||||||
# below the repo root that contains the ``bal`` package).
|
# below the repo root that contains the ``bal`` package).
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import WillItem, Will, HeirNotFoundException
|
|
||||||
from bal.core.heirs import Heirs
|
from bal.core.heirs import Heirs
|
||||||
|
from bal.core.reminders import (
|
||||||
|
compute_reminder_offsets,
|
||||||
|
format_time,
|
||||||
|
ical_escape,
|
||||||
|
write_temp_ics,
|
||||||
|
)
|
||||||
|
from bal.core.util import copy_structure
|
||||||
|
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.
|
||||||
@@ -133,7 +136,7 @@ def _make_willitem(**overrides):
|
|||||||
}
|
}
|
||||||
d.update(overrides)
|
d.update(overrides)
|
||||||
item = WillItem(d)
|
item = WillItem(d)
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -197,8 +200,8 @@ def test_e1_build_separate_events_for_karen7():
|
|||||||
|
|
||||||
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
||||||
for idx, offset in enumerate(offsets, start=1):
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
event_dt = format_time(locktime - timedelta(days=offset))
|
||||||
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
f"UID:bal-{wallet}-{offset}d",
|
f"UID:bal-{wallet}-{offset}d",
|
||||||
@@ -220,7 +223,7 @@ def test_e1_build_separate_events_for_karen7():
|
|||||||
assert any("(reminder 1/3)" in ln for ln in lines)
|
assert any("(reminder 1/3)" in ln for ln in lines)
|
||||||
assert any("(reminder 3/3)" in ln for ln in lines)
|
assert any("(reminder 3/3)" in ln for ln in lines)
|
||||||
# The LAST event (offset 1) sits one day before the locktime.
|
# The LAST event (offset 1) sits one day before the locktime.
|
||||||
last_dt = BalCalendar.format_time(locktime - timedelta(days=1))
|
last_dt = format_time(locktime - timedelta(days=1))
|
||||||
assert f"DTSTART:{last_dt}" in lines
|
assert f"DTSTART:{last_dt}" in lines
|
||||||
|
|
||||||
|
|
||||||
@@ -228,7 +231,7 @@ def test_e1_event_description_escaping():
|
|||||||
"""Special iCalendar characters in karen7's event text are escaped so
|
"""Special iCalendar characters in karen7's event text are escaped so
|
||||||
the .ics file stays valid."""
|
the .ics file stays valid."""
|
||||||
raw = "Wallet karen7; heirs: alice, bob"
|
raw = "Wallet karen7; heirs: alice, bob"
|
||||||
escaped = BalCalendar.ical_escape(raw)
|
escaped = ical_escape(raw)
|
||||||
assert "\\;" in escaped # semicolon escaped
|
assert "\\;" in escaped # semicolon escaped
|
||||||
assert "\\," in escaped # comma escaped
|
assert "\\," in escaped # comma escaped
|
||||||
assert "karen7" in escaped
|
assert "karen7" in escaped
|
||||||
@@ -246,7 +249,7 @@ def test_e1_write_temp_ics_for_karen7():
|
|||||||
"END:VEVENT\r\n"
|
"END:VEVENT\r\n"
|
||||||
"END:VCALENDAR\r\n"
|
"END:VCALENDAR\r\n"
|
||||||
)
|
)
|
||||||
path = BalCalendar.write_temp_ics(content)
|
path = write_temp_ics(content)
|
||||||
try:
|
try:
|
||||||
assert os.path.isfile(path)
|
assert os.path.isfile(path)
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
@@ -275,7 +278,7 @@ def test_e2_karen7_add_remove_heir():
|
|||||||
|
|
||||||
heirs["charlie"] = ["addr_charlie", "20000", "30d"]
|
heirs["charlie"] = ["addr_charlie", "20000", "30d"]
|
||||||
assert "charlie" in heirs
|
assert "charlie" in heirs
|
||||||
assert "charlie" in wallet.db.get("heirs", {})
|
assert "charlie" in (wallet.db.get("heirs") or {})
|
||||||
|
|
||||||
removed = heirs.pop("alice")
|
removed = heirs.pop("alice")
|
||||||
assert removed is not None
|
assert removed is not None
|
||||||
@@ -340,7 +343,7 @@ def test_e2_heir_change_triggers_rebuild():
|
|||||||
item = WillItem(
|
item = WillItem(
|
||||||
{
|
{
|
||||||
"tx": _VALID_TX_HEX,
|
"tx": _VALID_TX_HEX,
|
||||||
"heirs": copy.deepcopy(will_heirs),
|
"heirs": copy_structure(will_heirs),
|
||||||
"willexecutor": None,
|
"willexecutor": None,
|
||||||
"status": "",
|
"status": "",
|
||||||
"description": "",
|
"description": "",
|
||||||
@@ -349,7 +352,7 @@ def test_e2_heir_change_triggers_rebuild():
|
|||||||
"baltx_fees": 100,
|
"baltx_fees": 100,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
|
||||||
item.tx.locktime = lt
|
item.tx.locktime = lt
|
||||||
will = {"willid_1": item}
|
will = {"willid_1": item}
|
||||||
|
|
||||||
@@ -550,7 +553,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
|||||||
for txid, outputs in txo.items():
|
for txid, outputs in txo.items():
|
||||||
if not isinstance(outputs, dict):
|
if not isinstance(outputs, dict):
|
||||||
continue
|
continue
|
||||||
for addr, out_map in outputs.items():
|
for _, out_map in outputs.items():
|
||||||
if not isinstance(out_map, dict):
|
if not isinstance(out_map, dict):
|
||||||
continue
|
continue
|
||||||
for idx, info in out_map.items():
|
for idx, info in out_map.items():
|
||||||
@@ -567,8 +570,8 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
|||||||
h = Heirs.__new__(Heirs)
|
h = Heirs.__new__(Heirs)
|
||||||
h.update(heirs_data)
|
h.update(heirs_data)
|
||||||
|
|
||||||
assert len(h) == 4, f"expected 4 heirs, got {len(h)}"
|
assert len(h) == 6, f"expected 6 heirs, got {len(h)}"
|
||||||
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2"]
|
assert list(h.keys()) == ["aaaa", "lucia", "mario", "mario2", "op_return", "op_return2"]
|
||||||
|
|
||||||
# Mock the Electrum-heavy parts so the build can run in a test context.
|
# Mock the Electrum-heavy parts so the build can run in a test context.
|
||||||
wallet = MagicMock()
|
wallet = MagicMock()
|
||||||
@@ -623,6 +626,7 @@ def test_e5_build_with_real_wallet_heirs_and_utxos():
|
|||||||
"bal.core.heirs.PartialTransaction.from_io",
|
"bal.core.heirs.PartialTransaction.from_io",
|
||||||
side_effect=_fake_from_io,
|
side_effect=_fake_from_io,
|
||||||
):
|
):
|
||||||
|
result = None
|
||||||
try:
|
try:
|
||||||
result = h.buildTransactions(
|
result = h.buildTransactions(
|
||||||
bal_plugin, wallet, tx_fees=1, utxos=utxos
|
bal_plugin, wallet, tx_fees=1, utxos=utxos
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ Run:
|
|||||||
python3 tests/test_group_f_heir_change_rebuild.py
|
python3 tests/test_group_f_heir_change_rebuild.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.will import Will
|
from bal.core.will import Will
|
||||||
@@ -112,5 +113,5 @@ def test_same_heirs_empty():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import pytest
|
import pytest # pyright: ignore[reportMissingImports]
|
||||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ that would fall in the past.
|
|||||||
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
||||||
drives that decision.
|
drives that decision.
|
||||||
|
|
||||||
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
|
||||||
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
so these tests run without Qt (or Electrum) at all.
|
||||||
the other GUI tests.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
python3 -m pytest tests/test_group_g_basic_calendar.py -q
|
python3 tests/test_group_g_basic_calendar.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -24,8 +23,7 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
|
from bal.core.reminders import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
|
||||||
basic_reminder_offsets)
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_offsets_all_future():
|
def test_basic_offsets_all_future():
|
||||||
|
|||||||
@@ -3,117 +3,106 @@ Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
|
|||||||
|
|
||||||
Background (reported by the owner): with the plugin left in BASIC mode (the
|
Background (reported by the owner): with the plugin left in BASIC mode (the
|
||||||
default), the "Check Alive" (threshold) field is hidden from the user and
|
default), the "Check Alive" (threshold) field is hidden from the user and
|
||||||
stays stuck at its old/default value (roughly "today + 11 months", i.e. one
|
stays stuck at its old/default value. If the user then anticipates the
|
||||||
year minus the default 30-day margin). If the user then anticipates the
|
delivery time (locktime) to something earlier than that stale threshold, the
|
||||||
delivery time (locktime) to something earlier than that stale threshold - e.g.
|
checks that compare locktime against ``date_to_check`` would incorrectly treat
|
||||||
"in 1 month" - two different checks that compare locktime against
|
the will as "expired"/"invalid", even though the whole Check Alive concept is
|
||||||
``date_to_check`` (the resolved threshold) would incorrectly treat the will as
|
supposed to be inert in BASIC mode.
|
||||||
"expired"/"invalid", even though the whole Check Alive concept is supposed to
|
|
||||||
be inert in BASIC mode.
|
|
||||||
|
|
||||||
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track
|
The real fix lives in ``BalWindow.init_class_variables`` (window.py), which
|
||||||
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it,
|
sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
|
||||||
so it is always < locktime by construction. In ADVANCED mode nothing changes:
|
pure policy in ``bal.core.checkalive``:
|
||||||
the stored threshold is used as-is.
|
|
||||||
|
|
||||||
These tests call the real production method directly (no GUI/Electrum wallet
|
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
|
||||||
needed - it is a plain ``@staticmethod``), so they exercise the exact code
|
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
|
||||||
used at runtime rather than a re-implementation.
|
* ``ensure_check_alive_valid(is_basic_mode, ...)`` - whether the
|
||||||
|
Check-Alive guard should fire (never in BASIC).
|
||||||
|
|
||||||
|
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
|
||||||
|
needed, and no Qt import).
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
python3 tests/test_group_i_basic_checkalive.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
|
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||||
|
CheckAliveError,
|
||||||
|
ensure_check_alive_valid,
|
||||||
|
is_check_alive_date_valid,
|
||||||
|
resolve_date_to_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
def test_basic_mode_resolves_to_now():
|
||||||
|
"""BASIC mode: date_to_check is "now", never the stale stored threshold."""
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
|
||||||
|
assert result == fake_now
|
||||||
|
|
||||||
|
|
||||||
def test_offset_is_two_hours():
|
def test_basic_mode_ignores_stored_threshold():
|
||||||
"""Owner-approved margin: exactly 2 hours."""
|
"""BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
|
||||||
assert OFFSET == 2 * 60 * 60
|
does NOT jump to the old ~11-month threshold - this is the exact bug
|
||||||
|
scenario reported by the owner."""
|
||||||
|
stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
|
||||||
|
fake_now = 1_800_000_000.0
|
||||||
|
result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
|
||||||
|
assert result == fake_now
|
||||||
|
|
||||||
|
|
||||||
def _relative_days_to_midnight_timestamp(days):
|
def test_advanced_mode_uses_stored_threshold():
|
||||||
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N
|
"""ADVANCED mode: behaviour stays exactly as before - the stored threshold
|
||||||
days", truncated to midnight. Used to compute the expected value in
|
is used as-is, regardless of the locktime value."""
|
||||||
tests without duplicating the parsing logic itself."""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
now = datetime.now()
|
|
||||||
return (
|
|
||||||
(now + timedelta(days=days))
|
|
||||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
.timestamp()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_relative_locktime_one_year():
|
|
||||||
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier."""
|
|
||||||
result = BalWindow.compute_date_to_check(True, "1y", "30d")
|
|
||||||
expected_locktime = _relative_days_to_midnight_timestamp(365)
|
|
||||||
assert abs(result - (expected_locktime - OFFSET)) < 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_anticipated_delivery_stays_consistent():
|
|
||||||
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
|
|
||||||
NOT the old ~11-month threshold - this is the exact bug scenario
|
|
||||||
reported by the owner."""
|
|
||||||
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
|
|
||||||
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
|
|
||||||
result = BalWindow.compute_date_to_check(
|
|
||||||
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_basic_mode_date_to_check_always_before_locktime():
|
|
||||||
"""Regression guard for the reported bug: whatever the delivery date is
|
|
||||||
(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 = BalWindow.compute_date_to_check(False, "30d", absolute_threshold)
|
result = resolve_date_to_check(False, {"threshold": absolute_threshold})
|
||||||
assert abs(result - absolute_threshold) < 1
|
assert abs(result - absolute_threshold) < 1
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_falls_back_on_unparsable_locktime():
|
def test_advanced_mode_parses_relative_threshold():
|
||||||
"""If the locktime can't be parsed for any reason, BASIC mode must not
|
"""ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
|
||||||
crash: it falls back to the stored threshold, same as ADVANCED."""
|
result = resolve_date_to_check(False, {"threshold": "30d"})
|
||||||
absolute_threshold = time.time() + 5 * 86400
|
assert result > time.time()
|
||||||
result = BalWindow.compute_date_to_check(
|
|
||||||
True, {"not": "a valid locktime"}, absolute_threshold
|
|
||||||
)
|
def test_basic_mode_never_expired():
|
||||||
assert abs(result - absolute_threshold) < 1
|
"""BASIC mode can never be "expired": a passed check-alive date must never
|
||||||
|
force a postpone/rewrite of the will."""
|
||||||
|
assert ensure_check_alive_valid(True, time.time() - 10_000) is None
|
||||||
|
assert ensure_check_alive_valid(True, 1_000_000_000.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_expired_when_past():
|
||||||
|
assert is_check_alive_date_valid(time.time() - 10_000, None) is False
|
||||||
|
try:
|
||||||
|
ensure_check_alive_valid(False, time.time() - 10_000)
|
||||||
|
except CheckAliveError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("expected CheckAliveError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_mode_not_expired_when_future():
|
||||||
|
assert is_check_alive_date_valid(time.time() + 10_000, None) is True
|
||||||
|
|
||||||
|
|
||||||
|
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 ensure_check_alive_valid(True, date_to_check) is None
|
||||||
|
# If it did fire, this is the exact exception that would be raised.
|
||||||
|
assert issubclass(CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
test_offset_is_two_hours()
|
for name in sorted(dir()):
|
||||||
test_basic_mode_relative_locktime_one_year()
|
if name.startswith("test_"):
|
||||||
test_basic_mode_anticipated_delivery_stays_consistent()
|
globals()[name]()
|
||||||
test_basic_mode_date_to_check_always_before_locktime()
|
print(f" [OK] {name}")
|
||||||
test_advanced_mode_uses_stored_threshold_unchanged()
|
print("[OK] All test_group_i_basic_checkalive tests passed.")
|
||||||
test_basic_mode_falls_back_on_unparsable_locktime()
|
|
||||||
print("All test_group_i_basic_checkalive tests passed.")
|
|
||||||
|
|||||||
@@ -1,128 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.gui.qt.calendar``.
|
Tests for ``bal.gui.qt.calendar``.
|
||||||
|
|
||||||
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
|
Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
|
||||||
write_temp_ics, open_with_default_app.
|
The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
|
||||||
|
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.calendar import BalCalendar
|
from bal.gui.qt.calendar import BalCalendar
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# format_time
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_format_time_utc():
|
|
||||||
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
|
|
||||||
assert BalCalendar.format_time(dt) == "20250601T123045Z"
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_time_non_utc():
|
|
||||||
from datetime import timedelta
|
|
||||||
tz = timezone(timedelta(hours=2))
|
|
||||||
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
|
|
||||||
result = BalCalendar.format_time(dt)
|
|
||||||
assert result.endswith("Z")
|
|
||||||
assert result == "20250115T060000Z"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# ical_escape
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_ical_escape_no_change():
|
|
||||||
text = "hello world"
|
|
||||||
assert BalCalendar.ical_escape(text) == "hello world"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_backslash():
|
|
||||||
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_semicolon():
|
|
||||||
assert BalCalendar.ical_escape("a;b") == "a\\;b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_comma():
|
|
||||||
assert BalCalendar.ical_escape("a,b") == "a\\,b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_multiline():
|
|
||||||
text = "line1\r\nline2"
|
|
||||||
result = BalCalendar.ical_escape(text)
|
|
||||||
assert "\r\n" in result
|
|
||||||
assert "line1" in result
|
|
||||||
assert "line2" in result
|
|
||||||
|
|
||||||
|
|
||||||
def test_ical_escape_all():
|
|
||||||
text = "\\;,"
|
|
||||||
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# fold_ical_line
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_fold_ical_line_short():
|
|
||||||
line = "SUMMARY:Test"
|
|
||||||
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fold_ical_line_long():
|
|
||||||
line = "X-LONG:" + "a" * 100
|
|
||||||
result = BalCalendar.fold_ical_line(line, limit=75)
|
|
||||||
parts = result.split("\r\n ")
|
|
||||||
assert len(parts) > 1
|
|
||||||
assert result.startswith("X-LONG:")
|
|
||||||
|
|
||||||
|
|
||||||
def test_fold_ical_line_unicode():
|
|
||||||
line = "DESCRIPTION:" + "\u20ac" * 40
|
|
||||||
result = BalCalendar.fold_ical_line(line, limit=75)
|
|
||||||
assert "\r\n " in result
|
|
||||||
assert "\u20ac" in result
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# write_temp_ics
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_write_temp_ics():
|
|
||||||
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
|
||||||
path = BalCalendar.write_temp_ics(content)
|
|
||||||
try:
|
|
||||||
assert os.path.isfile(path)
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
assert f.read() == content.encode("utf-8")
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_temp_ics_empty():
|
|
||||||
path = BalCalendar.write_temp_ics("")
|
|
||||||
try:
|
|
||||||
assert os.path.isfile(path)
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
assert f.read() == b""
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# open_with_default_app
|
# open_with_default_app
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
def test_open_with_default_app_not_found():
|
def test_open_with_default_app_not_found():
|
||||||
result = BalCalendar.open_with_default_app(
|
result = BalCalendar.open_with_default_app(
|
||||||
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.gui.qt.common``.
|
Tests for ``bal.gui.qt.common``.
|
||||||
|
|
||||||
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui.
|
Covers shown_cv, add_widget, log_error, export_meta_gui (and the
|
||||||
|
CheckAliveError exception, which now lives in ``bal.core.checkalive``).
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
|
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel
|
||||||
|
|
||||||
# Import the module itself, not via "from .common import *"
|
# Import the module itself, not via "from .common import *"
|
||||||
import bal.gui.qt.common as C
|
import bal.gui.qt.common as common
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
@@ -23,18 +25,18 @@ _app = QApplication.instance() or QApplication(sys.argv)
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_shown_cv_default():
|
def test_shown_cv_default():
|
||||||
cv = C.shown_cv(True)
|
cv = common.shown_cv(True)
|
||||||
assert cv.get() is True
|
assert cv.get() is True
|
||||||
|
|
||||||
|
|
||||||
def test_shown_cv_set():
|
def test_shown_cv_set():
|
||||||
cv = C.shown_cv(True)
|
cv = common.shown_cv(True)
|
||||||
cv.set(False)
|
cv.set(False)
|
||||||
assert cv.get() is False
|
assert cv.get() is False
|
||||||
|
|
||||||
|
|
||||||
def test_shown_cv_roundtrip():
|
def test_shown_cv_roundtrip():
|
||||||
cv = C.shown_cv(False)
|
cv = common.shown_cv(False)
|
||||||
assert cv.get() is False
|
assert cv.get() is False
|
||||||
cv.set(True)
|
cv.set(True)
|
||||||
assert cv.get() is True
|
assert cv.get() is True
|
||||||
@@ -43,23 +45,30 @@ def test_shown_cv_roundtrip():
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# CheckAliveError
|
# CheckAliveError (moved to bal.core.checkalive; kept tested via the
|
||||||
|
# common->core import chain)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_check_alive_error_default():
|
def test_check_alive_error_default():
|
||||||
err = C.CheckAliveError(1000000)
|
from bal.core.checkalive import CheckAliveError
|
||||||
|
|
||||||
|
err = CheckAliveError(1000000)
|
||||||
assert err.timestamp_to_check == 1000000
|
assert err.timestamp_to_check == 1000000
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
def test_check_alive_error_str():
|
||||||
err = C.CheckAliveError(1000000)
|
from bal.core.checkalive import CheckAliveError
|
||||||
|
|
||||||
|
err = CheckAliveError(1000000)
|
||||||
s = str(err)
|
s = str(err)
|
||||||
assert "Check alive expired" in s
|
assert "Check alive expired" in s
|
||||||
assert "1970" in s
|
assert "1970" in s
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
def test_check_alive_error_subclass():
|
||||||
assert issubclass(C.CheckAliveError, Exception)
|
from bal.core.checkalive import CheckAliveError
|
||||||
|
|
||||||
|
assert issubclass(CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,17 +77,15 @@ def test_check_alive_error_subclass():
|
|||||||
|
|
||||||
def test_add_widget():
|
def test_add_widget():
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
parent = QWidget()
|
|
||||||
label = QLabel("test")
|
label = QLabel("test")
|
||||||
C.add_widget(grid, "Label", label, 0, "Help text")
|
common.add_widget(grid, "Label", label, 0, "Help text")
|
||||||
assert grid.count() == 3 # label + widget + help button
|
assert grid.count() == 3 # label + widget + help button
|
||||||
|
|
||||||
|
|
||||||
def test_add_widget_multiple_rows():
|
def test_add_widget_multiple_rows():
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
parent = QWidget()
|
common.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
||||||
C.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
common.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
||||||
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
|
||||||
assert grid.count() == 6
|
assert grid.count() == 6
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +94,7 @@ def test_add_widget_multiple_rows():
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_log_error_no_window():
|
def test_log_error_no_window():
|
||||||
C.log_error((Exception, Exception("test"), None))
|
common.log_error((Exception, Exception("test"), None))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
464
tests/test_gui_export_dialogs.py
Normal file
464
tests/test_gui_export_dialogs.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
Tests for the filter-based unified export/import dialogs and the BalWindow
|
||||||
|
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
|
||||||
|
|
||||||
|
Covers the shared export filters (All / Valid / Valid NC), the unified
|
||||||
|
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
|
||||||
|
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
|
||||||
|
receive flow) and the comma-separated tx-only file writer. The audio pages
|
||||||
|
run against a stub plugin so no sound hardware is exercised.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||||
|
|
||||||
|
import bal.gui.qt.dialogs as dialogs
|
||||||
|
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||||
|
_PNG_BYTES = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
|
||||||
|
# version 2); used for real-WillItem serialization tests.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, value):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def set(self, value, save=True):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
def __init__(self):
|
||||||
|
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||||
|
|
||||||
|
def read_file(self, path):
|
||||||
|
return _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow(QMainWindow):
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
def format_amount(self, amount):
|
||||||
|
return "{:.8f}".format(amount)
|
||||||
|
|
||||||
|
def format_amount_and_units(self, amount):
|
||||||
|
return "{:.8f} sat".format(amount)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAudioPlugin:
|
||||||
|
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.modem_config = None
|
||||||
|
|
||||||
|
def is_available(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModemConfig:
|
||||||
|
def __init__(self, kbps):
|
||||||
|
self.modem_bps = kbps * 1000
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBalWindow:
|
||||||
|
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||||
|
|
||||||
|
def __init__(self, audio_plugin=None):
|
||||||
|
self.window = FakeWindow()
|
||||||
|
self.bal_plugin = FakePlugin()
|
||||||
|
self.willitems = {}
|
||||||
|
self.audio_plugin = audio_plugin
|
||||||
|
self.bitrate_set = None
|
||||||
|
self.audio_payloads = []
|
||||||
|
|
||||||
|
def get_audio_modem_plugin(self):
|
||||||
|
return self.audio_plugin
|
||||||
|
|
||||||
|
def set_audio_modem_bitrate(self, kbps):
|
||||||
|
self.bitrate_set = kbps
|
||||||
|
if self.audio_plugin is not None:
|
||||||
|
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
|
||||||
|
|
||||||
|
def _audio_send_payload(self, payload):
|
||||||
|
self.audio_payloads.append(payload)
|
||||||
|
|
||||||
|
def export_json_file(self, path, will=None):
|
||||||
|
items = will if will is not None else self.willitems
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
|
||||||
|
|
||||||
|
def export_tx_file(self, path, will=None):
|
||||||
|
items = will if will is not None else self.willitems
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(",".join(str(wi.tx) for _, wi in items.items()))
|
||||||
|
|
||||||
|
|
||||||
|
class StubTx:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def txid(self):
|
||||||
|
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
class StubWillItem:
|
||||||
|
def __init__(self, payload, statuses=None):
|
||||||
|
self.tx = StubTx(payload)
|
||||||
|
self.statuses = statuses or {}
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self.statuses.get(name, False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"tx": str(self.tx)}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitems(n=3, payload_len=60, statuses=None):
|
||||||
|
return {
|
||||||
|
"item{}".format(i): StubWillItem(
|
||||||
|
"T{}".format(i) * payload_len, statuses=statuses
|
||||||
|
)
|
||||||
|
for i in range(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Shared export filters
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_export_filter_options():
|
||||||
|
opts = dialogs.export_filter_options()
|
||||||
|
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
|
||||||
|
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
valid = StubWillItem("V*", statuses={"VALID": True})
|
||||||
|
plain = StubWillItem("P*")
|
||||||
|
assert opts[0][1](complete) and opts[0][1](plain)
|
||||||
|
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
|
||||||
|
assert not opts[2][1](complete)
|
||||||
|
assert opts[2][1](valid) and not opts[2][1](plain)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_willitems_by_index():
|
||||||
|
items = {
|
||||||
|
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
|
||||||
|
"b": StubWillItem("B*", statuses={"VALID": True}),
|
||||||
|
"c": StubWillItem("C*"),
|
||||||
|
}
|
||||||
|
opts = dialogs.export_filter_options()
|
||||||
|
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
|
||||||
|
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
|
||||||
|
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillExportDialog file page
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_file_export_selects_by_filter():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(3)
|
||||||
|
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
|
||||||
|
items["item1"].statuses = {"VALID": True}
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
assert set(d._selected_items()) == set(items)
|
||||||
|
|
||||||
|
d._on_filter_change(1)
|
||||||
|
assert set(d._selected_items()) == {"item0", "item1"}
|
||||||
|
d._on_filter_change(2)
|
||||||
|
assert list(d._selected_items()) == ["item1"]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_export_run_tx_only(tmpdir):
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(3)
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
d.content_check.setChecked(False)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_gui(window, title, exporter):
|
||||||
|
captured["title"] = title
|
||||||
|
captured["exporter"] = exporter
|
||||||
|
|
||||||
|
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||||
|
d._export_file()
|
||||||
|
assert captured["title"] == "will_tx"
|
||||||
|
out = tmpdir.join("will_tx.txt").strpath
|
||||||
|
captured["exporter"](out)
|
||||||
|
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||||
|
assert open(out, encoding="utf-8").read() == expected
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_export_run_willitem(tmpdir):
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.content_check.isChecked()
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_gui(window, title, exporter):
|
||||||
|
captured["title"] = title
|
||||||
|
captured["exporter"] = exporter
|
||||||
|
|
||||||
|
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||||
|
d._export_file()
|
||||||
|
assert captured["title"] == "will"
|
||||||
|
out = tmpdir.join("will.json").strpath
|
||||||
|
captured["exporter"](out)
|
||||||
|
data = json.load(open(out, encoding="utf-8"))
|
||||||
|
assert set(data) == set(items)
|
||||||
|
assert data["item0"]["tx"] == str(items["item0"].tx)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_export_empty_under_filter_aborts():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = {
|
||||||
|
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
}
|
||||||
|
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||||
|
messages = []
|
||||||
|
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||||
|
d._filter_index = 2 # force an empty "Valid NC" selection
|
||||||
|
with patch.object(dialogs, "export_meta_gui") as gui:
|
||||||
|
d._export_file()
|
||||||
|
assert not gui.called
|
||||||
|
assert messages
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# BalWindow transport helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_bal_window_export_tx_file(tmpdir):
|
||||||
|
import bal.gui.qt.window as window
|
||||||
|
|
||||||
|
items = _make_willitems(3)
|
||||||
|
bw = object.__new__(window.BalWindow)
|
||||||
|
bw.willitems = items
|
||||||
|
out = tmpdir.join("will_tx.txt").strpath
|
||||||
|
bw.export_tx_file(out)
|
||||||
|
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||||
|
assert open(out, encoding="utf-8").read() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_bal_window_set_audio_modem_bitrate():
|
||||||
|
try:
|
||||||
|
import amodem.config
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
import bal.gui.qt.window as window
|
||||||
|
|
||||||
|
class P:
|
||||||
|
def __init__(self):
|
||||||
|
self.modem_config = None
|
||||||
|
|
||||||
|
probe = P()
|
||||||
|
bw = object.__new__(window.BalWindow)
|
||||||
|
bw.get_audio_modem_plugin = lambda: probe
|
||||||
|
bw.set_audio_modem_bitrate(1)
|
||||||
|
assert probe.modem_config is amodem.config.bitrates[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillExportDialog audio page
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_audio_export_page_send():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
items = _make_willitems(3, payload_len=30)
|
||||||
|
bw.willitems = items
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||||
|
assert d.audio_send_btn.isEnabled()
|
||||||
|
assert d.transport_audio.isEnabled()
|
||||||
|
assert d.kbps_combo.count() > 0
|
||||||
|
|
||||||
|
kbps = int(d.kbps_combo.currentText())
|
||||||
|
d._send_audio()
|
||||||
|
assert bw.bitrate_set == kbps
|
||||||
|
# Whole-will default: the audio payload is a single JSON document.
|
||||||
|
assert len(bw.audio_payloads) == 1
|
||||||
|
data = json.loads(bw.audio_payloads[0])
|
||||||
|
assert set(data) == set(items)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_export_page_send_tx_only():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
items = _make_willitems(3, payload_len=30)
|
||||||
|
bw.willitems = items
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
d.content_check.setChecked(False)
|
||||||
|
|
||||||
|
kbps = int(d.kbps_combo.currentText())
|
||||||
|
d._send_audio()
|
||||||
|
assert bw.bitrate_set == kbps
|
||||||
|
expected = "\n".join(dialogs.serialize_tx_list(items))
|
||||||
|
assert bw.audio_payloads == [expected]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_export_page_plugin_missing():
|
||||||
|
# The window stays usable: only the audio option is disabled.
|
||||||
|
bw = FakeBalWindow(audio_plugin=None)
|
||||||
|
bw.willitems = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert not d.audio_send_btn.isEnabled()
|
||||||
|
assert not d.transport_audio.isEnabled()
|
||||||
|
assert "not available" in d.audio_warn_label.text()
|
||||||
|
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||||
|
assert d.file_export_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillImportDialog audio page
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_audio_import_page_build():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.kbps_combo.count() > 0
|
||||||
|
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||||
|
assert d.receive_btn.isEnabled()
|
||||||
|
assert d.transport_audio.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_import_page_plugin_missing():
|
||||||
|
bw = FakeBalWindow(audio_plugin=None)
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert not d.receive_btn.isEnabled()
|
||||||
|
assert not d.transport_audio.isEnabled()
|
||||||
|
assert "not available" in d.audio_warn_label.text()
|
||||||
|
assert d.qr_page is not None # QR import still usable
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_import_receive_wiring():
|
||||||
|
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class FakeWaitingDialog:
|
||||||
|
def __init__(self, parent, msg, task, on_success=None, on_error=None):
|
||||||
|
captured["msg"] = msg
|
||||||
|
captured["task"] = task
|
||||||
|
captured["success"] = on_success
|
||||||
|
captured["error"] = on_error
|
||||||
|
|
||||||
|
imported = []
|
||||||
|
|
||||||
|
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
|
||||||
|
imported.append(payload)
|
||||||
|
|
||||||
|
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
|
||||||
|
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
|
||||||
|
dialogs, "_complete_import", side_effect=fake_complete
|
||||||
|
):
|
||||||
|
d._audio_receive()
|
||||||
|
kbps = int(d.kbps_combo.currentText())
|
||||||
|
assert bw.bitrate_set == kbps
|
||||||
|
assert captured["task"] is not None
|
||||||
|
captured["success"](blob)
|
||||||
|
# Payload is the raw decompressed text; autodetect handles the splitting.
|
||||||
|
assert imported == ["A" * 40 + "\n" + "B" * 40]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_qr_whole_will_json_serializes_transaction():
|
||||||
|
"""Regression: _whole_will_json must not raise
|
||||||
|
"Object of type Transaction is not JSON serializable".
|
||||||
|
|
||||||
|
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
|
||||||
|
QR payload (default content scope) must serialize it via MyEncoder the
|
||||||
|
same way write_json_file does.
|
||||||
|
"""
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
|
||||||
|
item = WillItem({
|
||||||
|
"tx": _VALID_TX_HEX,
|
||||||
|
"heirs": {},
|
||||||
|
"willexecutor": None,
|
||||||
|
"status": "",
|
||||||
|
"description": "",
|
||||||
|
"time": 0,
|
||||||
|
"change": "",
|
||||||
|
"baltx_fees": 100,
|
||||||
|
})
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillExportDialog(
|
||||||
|
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
|
||||||
|
)
|
||||||
|
j = d._whole_will_json()
|
||||||
|
data = json.loads(j)
|
||||||
|
assert data["imp0"]["tx"] == _VALID_TX_HEX
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
class _Path:
|
||||||
|
def __init__(self, d, name):
|
||||||
|
self.strpath = os.path.join(d, name)
|
||||||
|
|
||||||
|
class _Tmp:
|
||||||
|
def __init__(self):
|
||||||
|
self._d = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def join(self, name):
|
||||||
|
return _Path(self._d, name)
|
||||||
|
|
||||||
|
tmp = _Tmp()
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
fn = globals()[name]
|
||||||
|
fn(tmp) if inspect.signature(fn).parameters else fn()
|
||||||
|
print(" [OK] {}".format(name))
|
||||||
|
print("[OK] All export dialog GUI tests passed")
|
||||||
257
tests/test_gui_prepare_will_history.py
Normal file
257
tests/test_gui_prepare_will_history.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
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(
|
||||||
|
is_basic_mode=lambda: False,
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_purges_stale_wallet_history_before_building():
|
||||||
|
# The rebuild path must drop stale wallet-LOCAL will placeholders (saved by
|
||||||
|
# an earlier prepare) so their coins are available to the new build.
|
||||||
|
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(
|
||||||
|
is_basic_mode=lambda: False,
|
||||||
|
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"),
|
||||||
|
patch.object(Will, "remove_stale_wallet_history") as purge_mock,
|
||||||
|
):
|
||||||
|
BalWindow.build_inheritance_transaction(win)
|
||||||
|
purge_mock.assert_called_once_with(win.window.wallet, "LBL")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 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")
|
||||||
739
tests/test_gui_qr_transfer.py
Normal file
739
tests/test_gui_qr_transfer.py
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
"""
|
||||||
|
Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``).
|
||||||
|
|
||||||
|
Covers the unified ``WillExportDialog`` (transport radios, stacked pages,
|
||||||
|
QR build/navigation, chunk-size / autoplay, filter revert) and the unified
|
||||||
|
``WillImportDialog`` (QR frame capture, slot grid, complete-review enabling,
|
||||||
|
total mismatch reset, frame assembly -> decode). The QR transport now uses
|
||||||
|
only the BBQR wire format. The wizard and the camera/audio paths need a live
|
||||||
|
wallet/hardware and are exercised only through the shared frame-assembly
|
||||||
|
path here.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from electrum.transaction import Transaction
|
||||||
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||||
|
|
||||||
|
import bal.gui.qt.dialogs as dialogs
|
||||||
|
from bal.core.animated_qr import bbqr_frames
|
||||||
|
from bal.core.qrtransfer import CHUNK_PRESETS, encode_transfer
|
||||||
|
from bal.core.will import WillItem
|
||||||
|
|
||||||
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
# A valid 1x1 transparent PNG, good enough for BalDialog's window icon.
|
||||||
|
_PNG_BYTES = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output),
|
||||||
|
# reused for the WillItem status regression test.
|
||||||
|
_VALID_TX_HEX = (
|
||||||
|
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||||
|
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||||
|
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||||
|
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||||
|
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||||
|
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||||
|
"42146f11ef8414ae929feaafc388ac00000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rand_hex(n):
|
||||||
|
"""Incompressible lowercase hex string of ``n`` random bytes."""
|
||||||
|
return os.urandom(n).hex()
|
||||||
|
|
||||||
|
|
||||||
|
def _bbqr_frames(transfer, budget):
|
||||||
|
return bbqr_frames(transfer.encode("utf-8"), budget, encoding="Z")
|
||||||
|
|
||||||
|
|
||||||
|
class _Cfg:
|
||||||
|
def __init__(self, value):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def set(self, value, save=True):
|
||||||
|
self._value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
# Smallest QR preset: long transfers produce several frames.
|
||||||
|
def __init__(self):
|
||||||
|
self.QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||||
|
|
||||||
|
def read_file(self, path):
|
||||||
|
return _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow(QMainWindow):
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
def format_amount(self, amount):
|
||||||
|
return "{:.8f}".format(amount)
|
||||||
|
|
||||||
|
def format_amount_and_units(self, amount):
|
||||||
|
return "{:.8f} sat".format(amount)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBalWindow:
|
||||||
|
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.window = FakeWindow()
|
||||||
|
self.bal_plugin = FakePlugin()
|
||||||
|
self.willitems = {}
|
||||||
|
|
||||||
|
def get_audio_modem_plugin(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubTx:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def txid(self):
|
||||||
|
return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
class StubWillItem:
|
||||||
|
def __init__(self, payload, statuses=None):
|
||||||
|
self.tx = StubTx(payload)
|
||||||
|
self.statuses = statuses or {}
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self.statuses.get(name, False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"tx": str(self.tx), "status": self.statuses}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_willitems(n=3, payload_len=120, payloads=None):
|
||||||
|
if payloads is None:
|
||||||
|
payloads = [_rand_hex(payload_len) for _ in range(n)]
|
||||||
|
return {
|
||||||
|
"item{}".format(i): StubWillItem(p)
|
||||||
|
for i, p in enumerate(payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillExportDialog (QR transport via d.qr_page)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_export_dialog_builds():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems()
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert d.transport_qr.isChecked()
|
||||||
|
assert page.tx_strings
|
||||||
|
assert page.frames
|
||||||
|
assert len(page.frames) >= 1
|
||||||
|
# Frame 1 is shown, and it is a BBQR frame.
|
||||||
|
assert page.qr_view.text == page.frames[0]
|
||||||
|
assert page.frames[0].startswith("B$")
|
||||||
|
assert "1" in page.progress_label.text()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dialog_unified_transports():
|
||||||
|
# One window hosts the three transports as stacked, radio-selected pages.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems()
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert d.stacked.count() == 3
|
||||||
|
assert d.transport_file.isChecked()
|
||||||
|
assert d.stacked.currentWidget() is d.file_page
|
||||||
|
d._on_mode_clicked(d.MODE_QR)
|
||||||
|
assert d.stacked.currentWidget() is d.qr_page
|
||||||
|
d._on_mode_clicked(d.MODE_AUDIO)
|
||||||
|
assert d.stacked.currentWidget() is d.audio_page
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_dialog_qr_page_has_no_audio():
|
||||||
|
# Audio lives on the import dialog's own audio page, never in the QR page.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
assert not hasattr(page, "_audio_receive")
|
||||||
|
texts = [b.text() for b in page.findChildren(dialogs.QPushButton)]
|
||||||
|
assert not any("Audio" in t for t in texts)
|
||||||
|
assert d.receive_btn is not None
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dialog_empty_close():
|
||||||
|
# An empty will shows a modal message and no widgets are built; stub the
|
||||||
|
# message out for the test.
|
||||||
|
orig = dialogs.MessageBoxMixin.show_message
|
||||||
|
dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None
|
||||||
|
try:
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
assert not d.isVisible()
|
||||||
|
assert not hasattr(d, "qr_page")
|
||||||
|
d.close()
|
||||||
|
finally:
|
||||||
|
dialogs.MessageBoxMixin.show_message = orig
|
||||||
|
|
||||||
|
|
||||||
|
def test_imported_item_status_not_none():
|
||||||
|
# Regression: a WillItem built from a bare {"tx": hex} had a None status,
|
||||||
|
# so set_status (e.g. IMPORTED / INVALIDATED from the import validity
|
||||||
|
# pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and
|
||||||
|
# 'str'".
|
||||||
|
with patch.object(Transaction, "add_info_from_wallet"):
|
||||||
|
wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None)
|
||||||
|
assert wi.status == ""
|
||||||
|
assert wi.set_status("IMPORTED", True) is True
|
||||||
|
assert wi.set_status("INVALIDATED", True) is True
|
||||||
|
assert "Imported" in wi.status and "Invalidated" in wi.status
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_auto_scroll():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert page.fps_spin is not None
|
||||||
|
assert len(page.frames) > 1
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
|
||||||
|
page.fps_spin.setValue(2)
|
||||||
|
page._toggle_auto()
|
||||||
|
assert page.auto_timer.isActive()
|
||||||
|
assert page.auto_btn.text() == dialogs._("Stop")
|
||||||
|
page._auto_step()
|
||||||
|
assert page.index == 1
|
||||||
|
page._toggle_auto()
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
assert page.auto_btn.text() == dialogs._("Auto")
|
||||||
|
|
||||||
|
# Advancing past the last frame stops the slideshow automatically.
|
||||||
|
page._toggle_auto()
|
||||||
|
page.index = len(page.frames) - 1
|
||||||
|
page._auto_step()
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_auto_scroll_loop():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert page.loop_check is not None
|
||||||
|
assert len(page.frames) > 1
|
||||||
|
|
||||||
|
# Loop on: reaching the last code wraps back to the first and keeps going.
|
||||||
|
page.loop_check.setChecked(True)
|
||||||
|
page._toggle_auto()
|
||||||
|
assert page.auto_timer.isActive()
|
||||||
|
page.index = len(page.frames) - 1
|
||||||
|
page._auto_step()
|
||||||
|
assert page.index == 0
|
||||||
|
assert page.auto_timer.isActive()
|
||||||
|
page._toggle_auto()
|
||||||
|
|
||||||
|
# Loop off: reaching the last code stops the slideshow.
|
||||||
|
page.loop_check.setChecked(False)
|
||||||
|
page._toggle_auto()
|
||||||
|
page.index = len(page.frames) - 1
|
||||||
|
page._auto_step()
|
||||||
|
assert not page.auto_timer.isActive()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filter_valid_and_valid_nc():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
a = StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
b = StubWillItem(_rand_hex(120), statuses={"VALID": True})
|
||||||
|
c = StubWillItem(_rand_hex(120))
|
||||||
|
bw.willitems = {"a": a, "b": b, "c": c}
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert d.filter_combo.count() == 3
|
||||||
|
# Whole-will default: a single JSON document carrying all selected items.
|
||||||
|
assert d.content_check.isChecked()
|
||||||
|
assert len(page.tx_strings) == 1
|
||||||
|
data = json.loads(page.tx_strings[0])
|
||||||
|
assert set(data) == {"a", "b", "c"}
|
||||||
|
|
||||||
|
# Switch to tx-only content, then exercise the filters.
|
||||||
|
d.content_check.setChecked(False)
|
||||||
|
assert len(page.tx_strings) == 3
|
||||||
|
|
||||||
|
# "Valid" filter -> only the valid items (a, b).
|
||||||
|
d._on_filter_change(1)
|
||||||
|
assert sorted(page.tx_strings) == sorted([str(a.tx), str(b.tx)])
|
||||||
|
|
||||||
|
# "Valid NC" filter -> only the valid, not-complete item (b).
|
||||||
|
d._on_filter_change(2)
|
||||||
|
assert sorted(page.tx_strings) == [str(b.tx)]
|
||||||
|
assert page.qr_view.text == page.frames[0]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filter_empty_reverts():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
# Only a COMPLETE valid item: "Valid NC" selects nothing -> revert.
|
||||||
|
bw.willitems = {
|
||||||
|
"a": StubWillItem(_rand_hex(120), statuses={"VALID": True, "COMPLETE": True})
|
||||||
|
}
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
messages = []
|
||||||
|
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||||
|
d._on_filter_change(2) # "Valid NC" -> empty subset
|
||||||
|
assert messages
|
||||||
|
assert d._filter_index == 0 # reverted to "All"
|
||||||
|
assert d.filter_combo.currentIndex() == 0
|
||||||
|
assert len(d.qr_page.tx_strings) == 1
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_navigation_and_chunk_change():
|
||||||
|
# Incompressible serialized transactions resist deflate, so the BBQR
|
||||||
|
# transfer still needs several frames at the default chunk and navigation
|
||||||
|
# across frames is exercised.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
first_count = len(page.frames)
|
||||||
|
assert first_count > 1 # long transfer, small default chunk
|
||||||
|
|
||||||
|
assert not page.prev_btn.isEnabled()
|
||||||
|
page._next()
|
||||||
|
assert page.index == 1
|
||||||
|
assert page.qr_view.text == page.frames[1]
|
||||||
|
assert page.prev_btn.isEnabled()
|
||||||
|
page._prev()
|
||||||
|
assert page.index == 0
|
||||||
|
assert page.qr_view.text == page.frames[0]
|
||||||
|
|
||||||
|
# Switch to the largest preset: fewer, bigger frames.
|
||||||
|
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||||
|
assert len(page.frames) < first_count
|
||||||
|
assert page.index == 0
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_size_change_is_remembered():
|
||||||
|
# Changing the QR size inside the export dialog persists the choice so the
|
||||||
|
# next export starts from the last selected size.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(n=6, payload_len=400)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[0][1]
|
||||||
|
|
||||||
|
page._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1)
|
||||||
|
assert bw.bal_plugin.QR_CHUNK_SIZE.get() == dialogs.CHUNK_PRESETS[-1][1]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# WillImportDialog (QR transport via d.qr_page)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_import_frame_flow():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
assert not page.slot_area.isVisible()
|
||||||
|
|
||||||
|
transfer = encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)])
|
||||||
|
frames = _bbqr_frames(transfer, 150)
|
||||||
|
assert len(frames) > 1
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
page._add_frame(frame)
|
||||||
|
assert page.total == len(frames)
|
||||||
|
assert page.review_btn.isEnabled()
|
||||||
|
# isVisible() needs a shown parent; assert the widget is not hidden instead.
|
||||||
|
assert not page.slot_area.isHidden()
|
||||||
|
assert len(page.slot_widgets) == page.total
|
||||||
|
assert "All" in page.status_label.text()
|
||||||
|
# Duplicate capture is harmless.
|
||||||
|
page._add_frame(frames[0])
|
||||||
|
assert len(page.frames) == page.total
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_assembles_and_decodes():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_finish(payload):
|
||||||
|
captured["payload"] = payload
|
||||||
|
|
||||||
|
page._finish_import = fake_finish
|
||||||
|
tx_a = _rand_hex(130)
|
||||||
|
tx_b = _rand_hex(130)
|
||||||
|
transfer = encode_transfer([tx_a, tx_b])
|
||||||
|
for frame in _bbqr_frames(transfer, 150):
|
||||||
|
page._add_frame(frame)
|
||||||
|
page._review_and_sign()
|
||||||
|
# The payload is rejoined into an opaque string for autodetect.
|
||||||
|
assert captured["payload"].split("\n") == [tx_a, tx_b]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_will_payload_autodetect():
|
||||||
|
# Whole-will JSON is recognized as a will document.
|
||||||
|
payload = json.dumps({"item1": {"tx": "AAAA", "status": {"VALID": True}}})
|
||||||
|
kind, data = dialogs.decode_will_payload(payload)
|
||||||
|
assert kind == "will"
|
||||||
|
assert data["item1"]["tx"] == "AAAA"
|
||||||
|
|
||||||
|
# A singleton dict whose value is not an item dict falls back to txs.
|
||||||
|
kind, data = dialogs.decode_will_payload('{"foo": 1}')
|
||||||
|
assert kind == "txs"
|
||||||
|
|
||||||
|
# Comma and/or newline separated transactions.
|
||||||
|
kind, data = dialogs.decode_will_payload("AAAA,BBBB\nCCCC")
|
||||||
|
assert kind == "txs"
|
||||||
|
assert data == ["AAAA", "BBBB", "CCCC"]
|
||||||
|
|
||||||
|
# A single transaction with no separators.
|
||||||
|
kind, data = dialogs.decode_will_payload("HEXHEX")
|
||||||
|
assert kind == "txs"
|
||||||
|
assert data == ["HEXHEX"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_will_qr_roundtrip():
|
||||||
|
# "Whole will" produces a single JSON document that survives a full
|
||||||
|
# QR encode -> frame capture -> assemble -> decode cycle.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
items = _make_willitems(2)
|
||||||
|
bw.willitems = items
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
assert d.content_check.isChecked()
|
||||||
|
assert len(page.tx_strings) == 1
|
||||||
|
|
||||||
|
# Rebuild the transfer from the dialog's own strings, as the importer does.
|
||||||
|
transfer = encode_transfer(page.tx_strings)
|
||||||
|
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
import_page = impl.qr_page
|
||||||
|
for frame in _bbqr_frames(transfer, dialogs.CHUNK_PRESETS[0][1]):
|
||||||
|
import_page._add_frame(frame)
|
||||||
|
assert import_page.review_btn.isEnabled()
|
||||||
|
caught = {}
|
||||||
|
|
||||||
|
def fake_finish(payload):
|
||||||
|
caught["payload"] = payload
|
||||||
|
|
||||||
|
import_page._finish_import = fake_finish
|
||||||
|
import_page._review_and_sign()
|
||||||
|
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||||
|
assert kind == "will"
|
||||||
|
assert set(data) == {"item0", "item1"}
|
||||||
|
d.close()
|
||||||
|
impl.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_total_mismatch_resets():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
warnings = []
|
||||||
|
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
frames_b = _bbqr_frames(
|
||||||
|
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||||
|
)
|
||||||
|
for frame in frames_a:
|
||||||
|
page._add_frame(frame)
|
||||||
|
assert page.total == len(frames_a)
|
||||||
|
|
||||||
|
# A frame with a different total wipes the import; the first frame of
|
||||||
|
# the new transfer must be scanned afresh.
|
||||||
|
page._add_frame(frames_b[0])
|
||||||
|
assert warnings
|
||||||
|
assert page.total == 0
|
||||||
|
assert not page.frames
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_manual_entry():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
frames = _bbqr_frames(encode_transfer(["M" * 20]), 150)
|
||||||
|
page.manual_edit.setText(frames[0])
|
||||||
|
page._add_from_manual()
|
||||||
|
assert page.manual_edit.text() == ""
|
||||||
|
assert page.total == 1
|
||||||
|
assert page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Continuous camera scan (change/detection debounce + auto-finish)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _fresh_debounce():
|
||||||
|
return {
|
||||||
|
"last_index": None,
|
||||||
|
"last_payload": None,
|
||||||
|
"pending_index": None,
|
||||||
|
"pending_payload": None,
|
||||||
|
"pending_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_pending_then_accept():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
# First sighting of a new identity: pending, not yet stored.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "pending"
|
||||||
|
assert s["pending_count"] == 1
|
||||||
|
assert s["last_index"] is None
|
||||||
|
# A second stable read of the same identity: accepted.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "PAYLOAD1") == "accept"
|
||||||
|
assert s["last_index"] == 0
|
||||||
|
assert s["last_payload"] == "PAYLOAD1"
|
||||||
|
assert s["pending_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_re_reading_last_is_ignored():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accepted
|
||||||
|
# The exporter is still showing frame 0: must be ignored, not accepted.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") == "ignore"
|
||||||
|
assert s["last_index"] == 0
|
||||||
|
assert s["pending_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_transition_pending_resets():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1")
|
||||||
|
dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 0, "P1") # accept frame 0
|
||||||
|
# A new identity interrupts the pending accumulation.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "pending"
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "accept"
|
||||||
|
# Same-index duplicate with different payload is treated as new identity.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:2", 2, 1, "P2") == "ignore"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_debounce_total_mismatch_resets():
|
||||||
|
s = _fresh_debounce()
|
||||||
|
# In-range frame is accepted even though its declared total is ignored.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "pending"
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:3", 3, 1, "P2") == "accept"
|
||||||
|
# A frame that belongs to a different transfer.
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "reset"
|
||||||
|
# Once the policy is rebased on the new transfer, frames resume normally
|
||||||
|
# (the widget clears the debounce while wiping the import).
|
||||||
|
s["key"] = None
|
||||||
|
assert dialogs.qr_import_accept_frame(s, "bbqr", "bbqr:ZB:4", 4, 1, "P2B") == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_handle_scanned_text_autofinish():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
reviewed = []
|
||||||
|
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
assert len(frames) > 1
|
||||||
|
|
||||||
|
# The camera session is running and every frame needs two stable reads.
|
||||||
|
page._scanning = True
|
||||||
|
for frame in frames:
|
||||||
|
for _rep in range(2):
|
||||||
|
page._handle_scanned_text(frame)
|
||||||
|
assert page.total == len(frames)
|
||||||
|
assert len(page.frames) == len(frames)
|
||||||
|
assert page.review_btn.isEnabled()
|
||||||
|
|
||||||
|
# With all frames stored, the loop auto-finishes exactly once.
|
||||||
|
_app.processEvents()
|
||||||
|
assert reviewed == [True]
|
||||||
|
# The camera loop was stopped before handing over to the review step.
|
||||||
|
assert not page._scanning
|
||||||
|
assert not page._scan_timer.isActive()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_import_handle_scanned_text_manual_does_not_autofinish():
|
||||||
|
# Without a camera session running, extra frames never auto-proceed.
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
reviewed = []
|
||||||
|
page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
assert len(frames) > 1
|
||||||
|
assert not page._scanning
|
||||||
|
for frame in frames:
|
||||||
|
for _rep in range(2):
|
||||||
|
page._handle_scanned_text(frame)
|
||||||
|
assert len(page.frames) == len(frames)
|
||||||
|
_app.processEvents()
|
||||||
|
assert reviewed == []
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# BBQR import/export round trips
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_import_bbqr_roundtrip():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
frames = list(page.frames)
|
||||||
|
assert frames and all(f.startswith("B$") for f in frames)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
import_page = impl.qr_page
|
||||||
|
caught = {}
|
||||||
|
import_page._finish_import = lambda payload: caught.__setitem__("payload", payload)
|
||||||
|
for frame in frames:
|
||||||
|
import_page._add_frame(frame)
|
||||||
|
assert import_page.review_btn.isEnabled()
|
||||||
|
import_page._review_and_sign()
|
||||||
|
kind, data = dialogs.decode_will_payload(caught["payload"])
|
||||||
|
assert kind == "will"
|
||||||
|
assert set(data) == {"item0", "item1"}
|
||||||
|
impl.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_animated_scan_debounce_autofinish():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
bw.willitems = _make_willitems(2)
|
||||||
|
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin, initial_mode="qr")
|
||||||
|
page = d.qr_page
|
||||||
|
frames = list(page.frames)
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
impl = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
import_page = impl.qr_page
|
||||||
|
reviewed = []
|
||||||
|
import_page._review_and_sign = lambda: reviewed.append(True) # type: ignore[assignment]
|
||||||
|
import_page._scanning = True
|
||||||
|
for frame in frames:
|
||||||
|
for _rep in range(2):
|
||||||
|
import_page._handle_scanned_text(frame)
|
||||||
|
assert import_page.review_btn.isEnabled()
|
||||||
|
assert import_page.total == len(frames)
|
||||||
|
assert len(import_page.frames) >= import_page.total
|
||||||
|
_app.processEvents()
|
||||||
|
assert reviewed == [True]
|
||||||
|
assert not import_page._scanning
|
||||||
|
impl.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_garbage_scan_is_ignored():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
for garbage in ("hello world", "12345", "B$ZZ", "ur:bytes/xyz", ""):
|
||||||
|
page._handle_scanned_text(garbage)
|
||||||
|
assert not page.frames
|
||||||
|
assert page.total == 0
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_different_animated_transfer_resets():
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
warnings = []
|
||||||
|
page.show_warning = lambda msg: warnings.append(msg) # type: ignore[assignment]
|
||||||
|
|
||||||
|
frames_a = _bbqr_frames(encode_transfer([_rand_hex(120), _rand_hex(120)]), 150)
|
||||||
|
frames_b = _bbqr_frames(
|
||||||
|
encode_transfer([_rand_hex(120), _rand_hex(120), _rand_hex(120)]), 150
|
||||||
|
)
|
||||||
|
for frame in frames_a:
|
||||||
|
page._add_frame(frame)
|
||||||
|
assert page.total == len(frames_a)
|
||||||
|
assert not warnings
|
||||||
|
|
||||||
|
page._add_frame(frames_b[0])
|
||||||
|
assert warnings
|
||||||
|
assert page.total == 0
|
||||||
|
assert not page.frames
|
||||||
|
assert not page.review_btn.isEnabled()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_start_stop_scan_signal_wiring():
|
||||||
|
"""Regression: _start_scan/_stop_scan must use the QVideoSink signal
|
||||||
|
videoFrameChanged, not the videoFrame frame getter.
|
||||||
|
|
||||||
|
On PyQt6, ``QVideoSink.videoFrame`` is a method (the frame getter), so
|
||||||
|
``.videoFrame.connect(...)`` raises AttributeError. This test drives the
|
||||||
|
real sink life-cycle with a mocked camera and asserts the scan session
|
||||||
|
starts/ends cleanly with no error.
|
||||||
|
"""
|
||||||
|
from PyQt6.QtMultimedia import QCamera, QMediaCaptureSession, QMediaDevices
|
||||||
|
|
||||||
|
bw = FakeBalWindow()
|
||||||
|
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||||
|
page = d.qr_page
|
||||||
|
errors = []
|
||||||
|
page.show_error = lambda msg: errors.append(msg) # type: ignore[assignment]
|
||||||
|
|
||||||
|
fake_device = MagicMock()
|
||||||
|
fake_device.isNull.return_value = False
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(QMediaDevices, "defaultVideoInput", return_value=fake_device),
|
||||||
|
# Mock camera + capture session; the QVideoSink stays real so the
|
||||||
|
# videoFrameChanged connect/disconnect wiring is exercised for real.
|
||||||
|
patch.object(QCamera, "__new__", return_value=MagicMock()),
|
||||||
|
patch.object(QMediaCaptureSession, "__new__", return_value=MagicMock()),
|
||||||
|
):
|
||||||
|
page._start_scan()
|
||||||
|
assert page._scanning is True
|
||||||
|
assert not errors
|
||||||
|
|
||||||
|
page._stop_scan()
|
||||||
|
assert page._scanning is False
|
||||||
|
assert page._camera is None
|
||||||
|
assert page._video_sink is None
|
||||||
|
assert not errors
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All QR transfer GUI tests passed")
|
||||||
@@ -8,16 +8,19 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.theme import status_color
|
from bal.gui.qt.theme import signature_suffix, status_color
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
class FakeWillItem:
|
class FakeWillItem:
|
||||||
|
sigs_required = 0
|
||||||
|
sigs_have = 0
|
||||||
|
|
||||||
def __init__(self, **status_flags):
|
def __init__(self, **status_flags):
|
||||||
self._status = dict(status_flags)
|
self._status = dict(status_flags)
|
||||||
def get_status(self, name):
|
def get_status(self, name):
|
||||||
@@ -97,6 +100,54 @@ def test_color_check_fail_overrides_push_fail():
|
|||||||
assert status_color(item) == "#e83845"
|
assert status_color(item) == "#e83845"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# PARTIALLY_SIGNED
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_color_partially_signed():
|
||||||
|
assert status_color(FakeWillItem(PARTIALLY_SIGNED=True)) == "#ffb347"
|
||||||
|
|
||||||
|
|
||||||
|
def test_color_partially_signed_overridden_by_higher_priority():
|
||||||
|
item = FakeWillItem(PARTIALLY_SIGNED=True, PUSHED=True)
|
||||||
|
assert status_color(item) == "#73f3c8"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# signature_suffix
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_signature_suffix_partial():
|
||||||
|
item = FakeWillItem(PARTIALLY_SIGNED=True)
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 1
|
||||||
|
assert signature_suffix(item) == " (1/2)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_new():
|
||||||
|
item = FakeWillItem()
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 0
|
||||||
|
assert signature_suffix(item) == " (0/2)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_complete_empty():
|
||||||
|
item = FakeWillItem(COMPLETE=True)
|
||||||
|
item.sigs_required = 2
|
||||||
|
item.sigs_have = 1
|
||||||
|
assert signature_suffix(item) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_unknown_required_empty():
|
||||||
|
item = FakeWillItem()
|
||||||
|
item.sigs_have = 1
|
||||||
|
assert signature_suffix(item) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_signature_suffix_missing_fields_empty():
|
||||||
|
assert signature_suffix(FakeWillItem()) == ""
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -13,13 +13,11 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer
|
|
||||||
from PyQt6.QtWidgets import QApplication, QWidget
|
from PyQt6.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
|
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,23 +118,25 @@ def test_locktime_editor_min_max():
|
|||||||
def test_locktime_raw_edit_replace_str():
|
def test_locktime_raw_edit_replace_str():
|
||||||
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
||||||
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
||||||
# anymore (locktimes are always UNIX timestamps now).
|
# anymore (locktimes are always UNIX timestamps now). The helper moved to
|
||||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
# bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
|
||||||
assert LockTimeRawEdit.replace_str("123d") == "123"
|
from bal.core.input_rules import replace_dy_suffixes
|
||||||
assert LockTimeRawEdit.replace_str("456y") == "456"
|
assert replace_dy_suffixes("123d") == "123"
|
||||||
|
assert replace_dy_suffixes("456y") == "456"
|
||||||
# "b" is left untouched (no longer a recognised suffix)
|
# "b" is left untouched (no longer a recognised suffix)
|
||||||
assert LockTimeRawEdit.replace_str("789b") == "789b"
|
assert replace_dy_suffixes("789b") == "789b"
|
||||||
# only d/y are stripped; a stray "b" remains
|
# only d/y are stripped; a stray "b" remains
|
||||||
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b"
|
assert replace_dy_suffixes("12d34y56b") == "123456b"
|
||||||
|
|
||||||
|
|
||||||
def test_locktime_raw_edit_checkbdy():
|
def test_locktime_raw_edit_checkbdy():
|
||||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
# checkbdy moved to bal.core.input_rules (_checkbdy).
|
||||||
|
from bal.core.input_rules import _checkbdy
|
||||||
# character at expected position matches appendix
|
# character at expected position matches appendix
|
||||||
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d")
|
pos, s = _checkbdy("123d", 4, "d")
|
||||||
assert s == "123d"
|
assert s == "123d"
|
||||||
# character at expected position does not match
|
# character at expected position does not match
|
||||||
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d")
|
pos, s = _checkbdy("123x", 4, "d")
|
||||||
assert s == "123x"
|
assert s == "123x"
|
||||||
|
|
||||||
|
|
||||||
@@ -183,6 +183,22 @@ def test_locktime_raw_edit_get_set_value():
|
|||||||
assert "d" in val
|
assert "d" in val
|
||||||
|
|
||||||
|
|
||||||
|
def test_locktime_date_edit_get_set_value_roundtrip():
|
||||||
|
"""set_value(x) must roundtrip to get_value() == x (same timezone).
|
||||||
|
|
||||||
|
Guards a timezone regression that made the Date editor return the stored
|
||||||
|
wall clock re-read as local time, i.e. ``x + utc_offset``. That broke the
|
||||||
|
set_value/get_value roundtrip and kept the valueEdited ->
|
||||||
|
update_setting_widgets -> set_value signal cycle alive forever, ending in a
|
||||||
|
RecursionError when opening the "Build your will" wizard (Next button).
|
||||||
|
"""
|
||||||
|
from bal.gui.qt.widgets import LockTimeDateEdit
|
||||||
|
edit = LockTimeDateEdit()
|
||||||
|
for ts in (1700000000, 1750000000, 2147483647):
|
||||||
|
edit.set_value(ts)
|
||||||
|
assert edit.get_value() == ts
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# PercAmountEdit
|
# PercAmountEdit
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
508
tests/test_gui_will_flows.py
Normal file
508
tests/test_gui_will_flows.py
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""End-to-end GUI-level tests for the BAL will flows.
|
||||||
|
|
||||||
|
Exercises the real ``BalWindow`` controller (built via ``__new__`` to skip
|
||||||
|
the heavyweight tab construction) with a fake wallet/window, through the full
|
||||||
|
will lifecycle:
|
||||||
|
|
||||||
|
* prepare/build + persist (``prepare_will``)
|
||||||
|
* sign -> COMPLETE (``sign_transactions``)
|
||||||
|
* export -> merge roundtrip (``export_json_file`` / ``_load_will_file`` /
|
||||||
|
``merge_will``)
|
||||||
|
* insufficient-funds warning path
|
||||||
|
|
||||||
|
Run offline under the runtime venv:
|
||||||
|
|
||||||
|
source "$BAL_HOME/electrum/env/bin/activate"
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_flows.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from electrum import bitcoin, crypto # noqa: E402
|
||||||
|
from electrum.descriptor import parse_descriptor # noqa: E402
|
||||||
|
from electrum.transaction import ( # noqa: E402
|
||||||
|
PartialTxInput,
|
||||||
|
PartialTxOutput,
|
||||||
|
TxOutpoint,
|
||||||
|
tx_from_any,
|
||||||
|
)
|
||||||
|
from electrum.util import bfh # noqa: E402
|
||||||
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
|
||||||
|
from bal.core.heirs import Heirs # noqa: E402
|
||||||
|
from bal.core.plugin_base import BalPlugin # noqa: E402
|
||||||
|
from bal.core.util import Util # noqa: E402
|
||||||
|
from bal.core.will import Will # noqa: E402
|
||||||
|
from bal.core.willexecutors import Willexecutors # noqa: E402
|
||||||
|
from bal.gui.qt.window import BalWindow # noqa: E402
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Fixtures
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
PRIVKEY = bytes(range(32))
|
||||||
|
PUBKEY = crypto.privkey_to_pubkey(PRIVKEY)
|
||||||
|
ADDRESS = bitcoin.public_key_to_p2wpkh(PUBKEY)
|
||||||
|
SCRIPT = bitcoin.address_to_script(ADDRESS)
|
||||||
|
FUNDING_SATOSHIS = 500000
|
||||||
|
|
||||||
|
|
||||||
|
def make_funding_input():
|
||||||
|
"""Return a fake wallet UTXO spent by the will.
|
||||||
|
|
||||||
|
The input has no descriptor attached: the controller only needs
|
||||||
|
prevout + value + script for coin selection, and the wallet's
|
||||||
|
``sign_transaction`` re-attaches the descriptor after the PSBT
|
||||||
|
roundtrip drops it.
|
||||||
|
"""
|
||||||
|
utxo = PartialTxInput(prevout=TxOutpoint(bfh("11" * 32), 0))
|
||||||
|
# A real wallet input carries a witness UTXO, which is the ONLY field that
|
||||||
|
# survives the PSBT roundtrip the controller does (WillItem re-parses the tx
|
||||||
|
# via get_tx_from_any). Without it the re-parsed tx loses the input value
|
||||||
|
# and has no txid.
|
||||||
|
utxo.witness_utxo = PartialTxOutput.from_address_and_value(ADDRESS, FUNDING_SATOSHIS)
|
||||||
|
utxo._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
utxo._TxInput__scriptpubkey = SCRIPT
|
||||||
|
utxo._TxInput__address = ADDRESS
|
||||||
|
return utxo
|
||||||
|
|
||||||
|
|
||||||
|
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 get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWallet:
|
||||||
|
def __init__(self, utxos):
|
||||||
|
self.db = FakeDB()
|
||||||
|
self.adb = None
|
||||||
|
self.network = None
|
||||||
|
self._utxos = list(utxos)
|
||||||
|
self._dust = 546
|
||||||
|
self._change_addresses = [ADDRESS]
|
||||||
|
self.labels = {}
|
||||||
|
self.save_db_calls = 0
|
||||||
|
|
||||||
|
# -- wallet API used by the BAL core ---------------------------------- #
|
||||||
|
def save_db(self):
|
||||||
|
self.save_db_calls += 1
|
||||||
|
|
||||||
|
def dust_threshold(self):
|
||||||
|
return self._dust
|
||||||
|
|
||||||
|
def has_keystore_encryption(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_label(self, txid, label):
|
||||||
|
self.labels[txid] = label
|
||||||
|
|
||||||
|
def get_all_labels(self):
|
||||||
|
return dict(self.labels)
|
||||||
|
|
||||||
|
def get_label_for_txid(self, txid):
|
||||||
|
return self.labels.get(txid, "")
|
||||||
|
|
||||||
|
def get_utxos(self):
|
||||||
|
return list(self._utxos)
|
||||||
|
|
||||||
|
def get_change_addresses_for_new_transaction(self, *args, **kwargs):
|
||||||
|
return self._change_addresses
|
||||||
|
|
||||||
|
def add_input_info(self, txin, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_output_info(self, txout, only_der_suffix=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_tx_info(self, tx):
|
||||||
|
class _TxInfo:
|
||||||
|
def __init__(self):
|
||||||
|
class _MinedStatus:
|
||||||
|
def height(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
self.tx_mined_status = _MinedStatus()
|
||||||
|
|
||||||
|
return _TxInfo()
|
||||||
|
|
||||||
|
def get_transaction(self, txid):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- signing ----------------------------------------------------------- #
|
||||||
|
def sign_transaction(self, tx, password=None, ignore_warnings=True):
|
||||||
|
# The controller passes a re-parsed transaction whose inputs lost the
|
||||||
|
# descriptor and the trusted value through the PSBT roundtrip.
|
||||||
|
descriptor = parse_descriptor(f"wpkh({PUBKEY.hex()})")
|
||||||
|
for txin in tx.inputs():
|
||||||
|
if txin.script_descriptor is None:
|
||||||
|
txin.script_descriptor = descriptor
|
||||||
|
if txin.value_sats() is None:
|
||||||
|
txin._trusted_value_sats = FUNDING_SATOSHIS
|
||||||
|
tx.sign({PUBKEY: PRIVKEY})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConfig:
|
||||||
|
def __init__(self):
|
||||||
|
self._data = {}
|
||||||
|
self._tmpdir = tempfile.mkdtemp(prefix="bal-test-")
|
||||||
|
|
||||||
|
def electrum_path(self):
|
||||||
|
return self._tmpdir
|
||||||
|
|
||||||
|
def user_dir(self):
|
||||||
|
return self._tmpdir
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def set_key(self, key, value, save=True):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWindow:
|
||||||
|
def __init__(self, wallet):
|
||||||
|
self.wallet = wallet
|
||||||
|
self.messages = []
|
||||||
|
self.warnings = []
|
||||||
|
self.errors = []
|
||||||
|
|
||||||
|
def get_decimal_point(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def show_message(self, text):
|
||||||
|
self.messages.append(str(text))
|
||||||
|
|
||||||
|
def show_warning(self, text, parent=None, title=None):
|
||||||
|
self.warnings.append(str(text))
|
||||||
|
|
||||||
|
def show_error(self, text):
|
||||||
|
self.errors.append(str(text))
|
||||||
|
|
||||||
|
def show_critical(self, text):
|
||||||
|
self.errors.append(str(text))
|
||||||
|
|
||||||
|
def update_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(utxos=None):
|
||||||
|
"""Build a fully-wired BalWindow without constructing the Qt tabs."""
|
||||||
|
utxos = [make_funding_input()] if utxos is None else utxos
|
||||||
|
config = FakeConfig()
|
||||||
|
wallet = FakeWallet(utxos)
|
||||||
|
window = FakeWindow(wallet)
|
||||||
|
|
||||||
|
plugin = BalPlugin(None, config, "bal")
|
||||||
|
plugin.get_window_title = lambda title: str(title)
|
||||||
|
# BalWindow.__init__ wires these onto the plugin; replicate since we skip it.
|
||||||
|
plugin.get_decimal_point = window.get_decimal_point
|
||||||
|
# Self-will-executor mode; init_class_variables() reads this from config and
|
||||||
|
# would otherwise overwrite ctl.no_willexecutor with the (False) default.
|
||||||
|
plugin.NO_WILLEXECUTOR.set(True)
|
||||||
|
|
||||||
|
ctl = BalWindow.__new__(BalWindow)
|
||||||
|
ctl.bal_plugin = plugin
|
||||||
|
ctl.window = window
|
||||||
|
ctl.wallet = wallet
|
||||||
|
ctl.will = {}
|
||||||
|
ctl.willitems = {}
|
||||||
|
ctl.willexecutors = {}
|
||||||
|
ctl.will_settings = plugin.WILL_SETTINGS.get()
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
ctl.heirs = Heirs(wallet)
|
||||||
|
ctl.heirs["alice"] = [ADDRESS, "100000", "1y"]
|
||||||
|
ctl.heirs["bob"] = [ADDRESS, "100%", "1y"]
|
||||||
|
ctl.no_willexecutor = True
|
||||||
|
ctl.disable_plugin = False
|
||||||
|
ctl.update_all = lambda: None
|
||||||
|
ctl._schedule_history_refresh = lambda: None
|
||||||
|
return ctl
|
||||||
|
|
||||||
|
|
||||||
|
def _no_willexecutors():
|
||||||
|
"""Force an empty will-executor list (offline tests)."""
|
||||||
|
return mock.patch.object(
|
||||||
|
Willexecutors,
|
||||||
|
"get_willexecutors",
|
||||||
|
return_value={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single(controller):
|
||||||
|
"""Return (txid, WillItem) for the controller's single will item."""
|
||||||
|
assert len(controller.willitems) == 1, controller.willitems
|
||||||
|
return next(iter(controller.willitems.items()))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Tests
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def test_prepare_will_builds_and_persists():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
will = ctl.prepare_will()
|
||||||
|
|
||||||
|
assert will, "prepare_will must return the built will"
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
|
||||||
|
assert item.get_status("VALID"), "fresh items default to VALID"
|
||||||
|
assert txid == item.tx.txid()
|
||||||
|
assert isinstance(txid, str) and len(txid) == 64 and all(
|
||||||
|
c in "0123456789abcdef" for c in txid
|
||||||
|
), "raw tx id expected (64-char hex, not a label/short id)"
|
||||||
|
assert not item.tx.is_complete(), "unsigned will must not be complete"
|
||||||
|
assert isinstance(item.tx.locktime, int) and item.tx.locktime > 0
|
||||||
|
|
||||||
|
# Will item metadata mirrors the funded heir.
|
||||||
|
heirs = item.heirs
|
||||||
|
assert len(heirs) == 2
|
||||||
|
assert heirs["alice"][0] == ADDRESS and int(heirs["alice"][1]) == 100000
|
||||||
|
assert heirs["bob"][0] == ADDRESS
|
||||||
|
assert int(heirs["bob"][3]) > 0, "percent heir got a real amount"
|
||||||
|
assert int(heirs["alice"][3]) > 0
|
||||||
|
|
||||||
|
# The full wallet balance is swept into the will; the only leftover is the
|
||||||
|
# transaction fee.
|
||||||
|
total = sum(int(h[3]) for h in heirs.values())
|
||||||
|
assert FUNDING_SATOSHIS - total == item.tx.get_fee()
|
||||||
|
|
||||||
|
# Unsigned partial transaction serialises to PSBT base64.
|
||||||
|
serialized = str(item.tx)
|
||||||
|
assert not serialized.startswith("02"), "partial must not be raw hex"
|
||||||
|
|
||||||
|
# Nothing was pushed to the live history (no adb).
|
||||||
|
assert "history" not in ctl.wallet.db._data
|
||||||
|
assert ctl.wallet.save_db_calls == 0
|
||||||
|
|
||||||
|
# The rebuilt, ready-to-sign will is labelled for Electrum's History tab and
|
||||||
|
# the user is guided to the next manual step (sign).
|
||||||
|
assert ctl.wallet.labels[txid] == "BAL Inheritance transaction"
|
||||||
|
assert any("sign" in m.lower() for m in ctl.window.messages)
|
||||||
|
assert not ctl.window.warnings, "a clean build must not warn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_will_passes_after_build():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
assert ctl.check_will() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_transitions_to_complete():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
|
||||||
|
txs = ctl.sign_transactions(None)
|
||||||
|
|
||||||
|
assert txs and txid in txs
|
||||||
|
signed = txs[txid]
|
||||||
|
assert signed.is_complete(), "single-key p2wpkh must be fully signable"
|
||||||
|
|
||||||
|
# Mirror BalWindow.ask_password_and_sign_transactions.on_success: store
|
||||||
|
# the signed tx back into the will item and re-check signatures.
|
||||||
|
item.tx = Will.get_tx_from_any(str(signed))
|
||||||
|
Will.check_signatures(ctl.willitems, ctl.wallet)
|
||||||
|
|
||||||
|
assert item.get_status("COMPLETE"), "signed item must become COMPLETE"
|
||||||
|
assert item.sigs_have == 1 and item.sigs_required == 1
|
||||||
|
assert item.tx.inputs()[0].is_segwit()
|
||||||
|
|
||||||
|
# A complete tx serialises to raw hex.
|
||||||
|
raw = str(item.tx)
|
||||||
|
assert raw.startswith("02")
|
||||||
|
# The raw serialization must roundtrip back into the same complete tx.
|
||||||
|
reparsed = tx_from_any(raw)
|
||||||
|
assert reparsed.is_complete()
|
||||||
|
assert reparsed.txid() == signed.txid()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_and_merge_roundtrip():
|
||||||
|
with _no_willexecutors():
|
||||||
|
src = make_controller()
|
||||||
|
src.prepare_will()
|
||||||
|
txid, _ = _single(src)
|
||||||
|
signed = src.sign_transactions(None)
|
||||||
|
item = src.willitems[txid]
|
||||||
|
item.tx = Will.get_tx_from_any(str(signed[txid]))
|
||||||
|
Will.check_signatures(src.willitems, src.wallet)
|
||||||
|
|
||||||
|
assert item.get_status("COMPLETE")
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="bal-export-")
|
||||||
|
path = os.path.join(tmpdir, "will.json")
|
||||||
|
src.export_json_file(path, will=src.willitems)
|
||||||
|
|
||||||
|
# The exported file is a JSON dict keyed by txid; the signed tx is
|
||||||
|
# serialised as raw hex via MyEncoder.
|
||||||
|
with open(path) as f:
|
||||||
|
exported = json.load(f)
|
||||||
|
assert list(exported) == [txid]
|
||||||
|
assert exported[txid]["tx"] == str(item.tx)
|
||||||
|
assert exported[txid]["COMPLETE"] is True
|
||||||
|
assert exported[txid]["VALID"] is True
|
||||||
|
|
||||||
|
# Fresh controller receives the exported will.
|
||||||
|
dst = make_controller()
|
||||||
|
imported = dst._load_will_file(path)
|
||||||
|
dst.merge_will(imported)
|
||||||
|
|
||||||
|
assert len(dst.willitems) == 1
|
||||||
|
dst_txid, dst_item = _single(dst)
|
||||||
|
assert dst_txid == txid, "merge must keep the same txid"
|
||||||
|
assert dst_item.get_status("COMPLETE"), "signed status must survive merge"
|
||||||
|
assert str(dst_item.tx) == str(item.tx), "signed raw tx must survive roundtrip"
|
||||||
|
assert dst_item.tx.is_complete()
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_funds_warns():
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller(utxos=[])
|
||||||
|
|
||||||
|
will = ctl.prepare_will()
|
||||||
|
|
||||||
|
assert not will, "no wallet funds must not build a will"
|
||||||
|
assert ctl.window.warnings, "user must be warned about the balance"
|
||||||
|
assert any("adjustment" in w.lower() for w in ctl.window.warnings)
|
||||||
|
assert not ctl.willitems
|
||||||
|
|
||||||
|
|
||||||
|
def test_guard_not_blocked_by_old_built_will():
|
||||||
|
"""Regression: shortening the delivery in the STORED settings (relative
|
||||||
|
"1y"/"30d") while an old, still-VALID built will is frozen at a longer
|
||||||
|
locktime must NOT fire the "locktime is lower than threshold" guard.
|
||||||
|
|
||||||
|
The old guard compared the fresh settings locktime against ``date_to_check``
|
||||||
|
anchored to the built will (see ``resolve_date_to_check``), so a built-will
|
||||||
|
delivery longer than the settings' one made it fire even though the settings
|
||||||
|
are internally consistent (locktime is 30d AFTER the threshold). The guard
|
||||||
|
must instead compare the stored settings on a single reference frame
|
||||||
|
(``BalWindow.is_locktime_below_threshold``); ``date_to_check`` keeps its
|
||||||
|
built anchor for the expiry/validity checks.
|
||||||
|
"""
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced") # ADVANCED Check-Alive mode
|
||||||
|
ctl.prepare_will()
|
||||||
|
txid, item = _single(ctl)
|
||||||
|
|
||||||
|
# Freeze the built (VALID) will at a delivery one year longer than the
|
||||||
|
# now-shortened settings: the pre-fix guard would reject the rebuild.
|
||||||
|
item.tx.locktime = item.tx.locktime + 365 * 86400
|
||||||
|
ctl.will_settings = {"locktime": "1y", "threshold": "-30d"}
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
|
||||||
|
ctl.init_class_variables()
|
||||||
|
|
||||||
|
# date_to_check is anchored to the built will (long delivery)...
|
||||||
|
assert ctl.date_to_check == item.tx.locktime - 30 * 86400
|
||||||
|
# ...and the OLD guard would have fired here:
|
||||||
|
old_locktime = Util.parse_locktime_string(ctl.will_settings["locktime"])
|
||||||
|
assert old_locktime < ctl.date_to_check
|
||||||
|
# but the settings themselves are consistent, so the guard must pass:
|
||||||
|
assert ctl.is_locktime_below_threshold() is False
|
||||||
|
assert not ctl.window.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_anticipated_rebuild_reanchors_date_to_check():
|
||||||
|
"""Regression (karen7): rebuilding a SIGNED will whose delivery was
|
||||||
|
anticipated (per-heir recipes shortened from 2y to 1y, ADVANCED mode) must
|
||||||
|
succeed.
|
||||||
|
|
||||||
|
``date_to_check`` stays anchored to the OLD built delivery for the validity
|
||||||
|
checks, but ``build_will`` must re-anchor it to the NEW (earliest current)
|
||||||
|
delivery as its build filter: before the fix the stale 2028 anchor rejected
|
||||||
|
every "1y" heir (cmp <= 0 in ``fixed_percent_lists_amount``) and the build
|
||||||
|
reported ``NO_FUTURE_DATE``. The old signed item is then superseded by
|
||||||
|
``search_rai`` (REPLACED -> no on-chain invalidation) and the rebuilt will
|
||||||
|
is coherent again.
|
||||||
|
"""
|
||||||
|
with _no_willexecutors():
|
||||||
|
ctl = make_controller()
|
||||||
|
ctl.bal_plugin.USER_TYPE.set("advanced")
|
||||||
|
# Per-heir deliveries require multiverse mode (the only way heirs can
|
||||||
|
# carry a different recipe than the settings locktime).
|
||||||
|
ctl.bal_plugin.ENABLE_MULTIVERSE.set(True)
|
||||||
|
ctl.will_settings = {"locktime": "2y", "threshold": "-150d", "baltx_fees": 20}
|
||||||
|
Util.fix_will_settings_tx_fees(ctl.will_settings)
|
||||||
|
ctl.heirs["alice"][2] = "2y"
|
||||||
|
ctl.heirs["bob"][2] = "2y"
|
||||||
|
|
||||||
|
# Build and sign a 2y will (the old, committed delivery).
|
||||||
|
ctl.prepare_will()
|
||||||
|
old_txid, _old_item = _single(ctl)
|
||||||
|
old_locktime = _old_item.tx.locktime
|
||||||
|
signed = ctl.sign_transactions(None)
|
||||||
|
_old_item.tx = Will.get_tx_from_any(str(signed[old_txid]))
|
||||||
|
Will.check_signatures(ctl.willitems, ctl.wallet)
|
||||||
|
assert _old_item.get_status("COMPLETE")
|
||||||
|
|
||||||
|
# Anticipate: shorten every heir to 1y.
|
||||||
|
ctl.heirs["alice"][2] = "1y"
|
||||||
|
ctl.heirs["bob"][2] = "1y"
|
||||||
|
|
||||||
|
ctl.init_class_variables()
|
||||||
|
# date_to_check stays anchored to the OLD built delivery...
|
||||||
|
assert ctl.date_to_check == old_locktime - 150 * 86400
|
||||||
|
# ...and that stale anchor would reject the anticipated "1y" dates.
|
||||||
|
assert Util.parse_locktime_string("1y") < ctl.date_to_check
|
||||||
|
|
||||||
|
# The rebuild must succeed (re-anchored to the new delivery).
|
||||||
|
willitems = ctl.build_inheritance_transaction()
|
||||||
|
|
||||||
|
assert ctl.heirs.last_build_error is None, "NO_FUTURE_DATE must not fire"
|
||||||
|
new_valid = [
|
||||||
|
it for tid, it in willitems.items()
|
||||||
|
if tid != old_txid and it.get_status("VALID")
|
||||||
|
]
|
||||||
|
assert new_valid, "the anticipated (1y) will must build and stay VALID"
|
||||||
|
new_item = new_valid[0]
|
||||||
|
assert new_item.tx.locktime < old_locktime, "delivery must be anticipated"
|
||||||
|
# date_to_check was re-anchored to the rebuilt delivery (1y minus 150d).
|
||||||
|
assert abs(ctl.date_to_check - (new_item.tx.locktime - 150 * 86400)) < 3600
|
||||||
|
|
||||||
|
# The old signed item is kept but superseded (REPLACED -> not VALID).
|
||||||
|
assert _old_item.get_status("REPLACED") is True
|
||||||
|
assert _old_item.get_status("VALID") is False
|
||||||
|
|
||||||
|
# The rebuilt will is coherent (plain rebuild, no on-chain invalidation).
|
||||||
|
assert ctl.check_will() is True
|
||||||
|
assert not any("delivery date" in m for m in ctl.window.messages)
|
||||||
|
assert not ctl.window.errors
|
||||||
|
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
tests = [fn for name, fn in sorted(globals().items()) if name.startswith("test_")]
|
||||||
|
for fn in tests:
|
||||||
|
print(f"{fn.__name__} ... ", end="", flush=True)
|
||||||
|
fn()
|
||||||
|
print("OK")
|
||||||
|
print(f"\n{len(tests)} tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
_run_all()
|
||||||
150
tests/test_gui_will_menu.py
Normal file
150
tests/test_gui_will_menu.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Will-tab context menu and Will-Executor bulk-selection helpers
|
||||||
|
in ``bal.gui.qt.lists``.
|
||||||
|
|
||||||
|
Covers ``_can_sign`` / ``_can_broadcast`` / ``_can_delete`` and
|
||||||
|
``_apply_select_all`` (the pure logic behind the new context-menu actions and
|
||||||
|
the "Select All" dropdown).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_menu.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from bal.gui.qt.lists import (
|
||||||
|
_apply_select_all,
|
||||||
|
_can_broadcast,
|
||||||
|
_can_delete,
|
||||||
|
_can_sign,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWillItem:
|
||||||
|
"""Minimal stand-in for ``bal.core.will.WillItem`` (get_status only)."""
|
||||||
|
|
||||||
|
def __init__(self, **status_flags):
|
||||||
|
self._status = dict(status_flags)
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self._status.get(name, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _we(selected=False):
|
||||||
|
return {"selected": selected}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _can_sign / _can_broadcast
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_can_sign_unsigned():
|
||||||
|
assert _can_sign(FakeWillItem()) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_partially_signed():
|
||||||
|
assert _can_sign(FakeWillItem(PARTIALLY_SIGNED=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_complete_false():
|
||||||
|
assert _can_sign(FakeWillItem(COMPLETE=True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_none_false():
|
||||||
|
assert _can_sign(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_broadcast_complete():
|
||||||
|
assert _can_broadcast(FakeWillItem(COMPLETE=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_broadcast_unsigned_false():
|
||||||
|
assert _can_broadcast(FakeWillItem()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_broadcast_none_false():
|
||||||
|
assert _can_broadcast(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _can_delete
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_can_delete_invalid():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=False, COMPLETE=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_unsigned():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_invalid_and_unsigned():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=False)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_valid_and_complete_false():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=True, COMPLETE=True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_none_false():
|
||||||
|
assert _can_delete(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _apply_select_all
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_select_all_sets_everything():
|
||||||
|
wes = {"a": _we(False), "b": _we(False)}
|
||||||
|
_apply_select_all(wes, True)
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deselect_all_clears_everything():
|
||||||
|
wes = {"a": _we(True), "b": _we(True)}
|
||||||
|
_apply_select_all(wes, False)
|
||||||
|
assert wes["a"]["selected"] is False
|
||||||
|
assert wes["b"]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_only_valid():
|
||||||
|
wes = {"a": _we(False), "b": _we(True), "c": _we(True)}
|
||||||
|
valid = {"a": True, "b": True, "c": False}
|
||||||
|
_apply_select_all(wes, True, valid)
|
||||||
|
# a, b are valid -> selected; c is invalid -> deselected
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is True
|
||||||
|
assert wes["c"]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deselect_only_invalid():
|
||||||
|
wes = {"a": _we(True), "b": _we(True), "c": _we(True)}
|
||||||
|
valid = {"a": True, "b": False, "c": True}
|
||||||
|
_apply_select_all(wes, False, valid)
|
||||||
|
# b is invalid -> deselected; a, c are valid -> keep their state
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is False
|
||||||
|
assert wes["c"]["selected"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_only_valid_missing_url_treated_invalid():
|
||||||
|
wes = {"a": _we(False), "b": _we(True)}
|
||||||
|
valid = {"a": True}
|
||||||
|
_apply_select_all(wes, True, valid)
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All will-menu tests passed")
|
||||||
@@ -8,13 +8,18 @@ Run:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from PyQt6.QtCore import QTimer
|
from PyQt6.QtCore import QTimer
|
||||||
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
||||||
|
|
||||||
from bal.gui.qt.window_utils import (
|
from bal.gui.qt.window_utils import (
|
||||||
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of,
|
bring_to_front,
|
||||||
|
show_modal,
|
||||||
|
show_on_top,
|
||||||
|
stop_thread,
|
||||||
|
top_level_of,
|
||||||
)
|
)
|
||||||
|
|
||||||
_app = QApplication.instance() or QApplication(sys.argv)
|
_app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user